From 700fe5e46350be70543649435a727217bec08e2e Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Thu, 1 Feb 2024 17:59:20 -0400 Subject: [PATCH] Add Find By Username and Find By Phone Number interstitials. Co-authored-by: Greyson Parrelli --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 7 +- .../securesms/ContactSelectionActivity.java | 7 + .../securesms/ContactSelectionListAdapter.kt | 38 +- .../ContactSelectionListFragment.java | 33 +- .../securesms/NewConversationActivity.java | 32 +- .../components/ContactFilterView.java | 10 +- .../ui/addmembers/AddMembersActivity.java | 27 +- .../ui/creategroup/CreateGroupActivity.java | 28 +- .../PhoneNumberVisualTransformation.kt | 58 ++ .../recipients/ui/findby/FindByActivity.kt | 558 ++++++++++++++++++ .../recipients/ui/findby/FindByMode.kt | 11 + .../recipients/ui/findby/FindByResult.kt | 14 + .../recipients/ui/findby/FindByState.kt | 20 + .../recipients/ui/findby/FindByViewModel.kt | 118 ++++ .../drawable/symbol_dropdown_triangle_24.xml | 9 + .../main/res/drawable/symbol_number_24.xml | 9 + .../main/res/layout/add_members_activity.xml | 15 +- .../res/layout/contact_selection_activity.xml | 9 +- ...ct_selection_find_by_phone_number_item.xml | 41 ++ ...ontact_selection_find_by_username_item.xml | 41 ++ .../main/res/layout/create_group_activity.xml | 7 +- app/src/main/res/values/attrs.xml | 2 +- app/src/main/res/values/strings.xml | 40 ++ .../PhoneNumberVisualTransformationTest.kt | 88 +++ .../main/java/org/signal/core/ui/Dialogs.kt | 14 +- .../main/java/org/signal/core/ui/Dividers.kt | 33 ++ .../java/org/signal/core/ui/TextFields.kt | 124 ++++ 28 files changed, 1357 insertions(+), 37 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/phonenumbers/PhoneNumberVisualTransformation.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByActivity.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByMode.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByResult.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByState.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByViewModel.kt create mode 100644 app/src/main/res/drawable/symbol_dropdown_triangle_24.xml create mode 100644 app/src/main/res/drawable/symbol_number_24.xml create mode 100644 app/src/main/res/layout/contact_selection_find_by_phone_number_item.xml create mode 100644 app/src/main/res/layout/contact_selection_find_by_username_item.xml create mode 100644 app/src/test/java/org/thoughtcrime/securesms/phonenumbers/PhoneNumberVisualTransformationTest.kt create mode 100644 core-ui/src/main/java/org/signal/core/ui/TextFields.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index eb0a66423b..6976230673 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -509,6 +509,7 @@ dependencies { implementation(libs.androidx.lifecycle.viewmodel.savedstate) implementation(libs.androidx.lifecycle.common.java8) implementation(libs.androidx.lifecycle.reactivestreams.ktx) + implementation(libs.androidx.activity.compose) implementation(libs.androidx.camera.core) implementation(libs.androidx.camera.camera2) implementation(libs.androidx.camera.lifecycle) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 16526e7a17..993dede288 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -682,7 +682,12 @@ + + diff --git a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionActivity.java b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionActivity.java index 63e76eb580..0420991f46 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionActivity.java @@ -24,14 +24,17 @@ import androidx.annotation.NonNull; import androidx.appcompat.widget.Toolbar; import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; +import org.signal.core.util.DimensionUnit; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.components.ContactFilterView; import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode; import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.DisplayMetricsUtil; import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; import org.thoughtcrime.securesms.util.DynamicTheme; +import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.ServiceUtil; import org.thoughtcrime.securesms.util.Util; @@ -99,6 +102,10 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit private void initializeContactFilterView() { this.contactFilterView = findViewById(R.id.contact_filter_edit_text); + + if (getResources().getDisplayMetrics().heightPixels >= DimensionUnit.DP.toPixels(600) || !FeatureFlags.usernames()) { + this.contactFilterView.focusAndShowKeyboard(); + } } private void initializeToolbar() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListAdapter.kt index 9a595faed0..ab63ad61d1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListAdapter.kt @@ -27,6 +27,8 @@ class ContactSelectionListAdapter( registerFactory(RefreshContactsModel::class.java, LayoutFactory({ RefreshContactsViewHolder(it, onClickCallbacks::onRefreshContactsClicked) }, R.layout.contact_selection_refresh_action_item)) registerFactory(MoreHeaderModel::class.java, LayoutFactory({ MoreHeaderViewHolder(it) }, R.layout.contact_search_section_header)) registerFactory(EmptyModel::class.java, LayoutFactory({ EmptyViewHolder(it) }, R.layout.contact_selection_empty_state)) + registerFactory(FindByUsernameModel::class.java, LayoutFactory({ FindByUsernameViewHolder(it, onClickCallbacks::onFindByUsernameClicked) }, R.layout.contact_selection_find_by_username_item)) + registerFactory(FindByPhoneNumberModel::class.java, LayoutFactory({ FindByPhoneNumberViewHolder(it, onClickCallbacks::onFindByPhoneNumberClicked) }, R.layout.contact_selection_find_by_phone_number_item)) } class NewGroupModel : MappingModel { @@ -44,6 +46,16 @@ class ContactSelectionListAdapter( override fun areContentsTheSame(newItem: RefreshContactsModel): Boolean = true } + class FindByUsernameModel : MappingModel { + override fun areItemsTheSame(newItem: FindByUsernameModel): Boolean = true + override fun areContentsTheSame(newItem: FindByUsernameModel): Boolean = true + } + + class FindByPhoneNumberModel : MappingModel { + override fun areItemsTheSame(newItem: FindByPhoneNumberModel): Boolean = true + override fun areContentsTheSame(newItem: FindByPhoneNumberModel): Boolean = true + } + class MoreHeaderModel : MappingModel { override fun areItemsTheSame(newItem: MoreHeaderModel): Boolean = true @@ -92,13 +104,33 @@ class ContactSelectionListAdapter( } } + private class FindByPhoneNumberViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder(itemView) { + + init { + itemView.setOnClickListener { onClickListener() } + } + + override fun bind(model: FindByPhoneNumberModel) = Unit + } + + private class FindByUsernameViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder(itemView) { + + init { + itemView.setOnClickListener { onClickListener() } + } + + override fun bind(model: FindByUsernameModel) = Unit + } + class ArbitraryRepository : org.thoughtcrime.securesms.contacts.paged.ArbitraryRepository { enum class ArbitraryRow(val code: String) { NEW_GROUP("new-group"), INVITE_TO_SIGNAL("invite-to-signal"), MORE_HEADING("more-heading"), - REFRESH_CONTACTS("refresh-contacts"); + REFRESH_CONTACTS("refresh-contacts"), + FIND_BY_USERNAME("find-by-username"), + FIND_BY_PHONE_NUMBER("find-by-phone-number"); companion object { fun fromCode(code: String) = values().first { it.code == code } @@ -120,6 +152,8 @@ class ContactSelectionListAdapter( ArbitraryRow.INVITE_TO_SIGNAL -> InviteToSignalModel() ArbitraryRow.MORE_HEADING -> MoreHeaderModel() ArbitraryRow.REFRESH_CONTACTS -> RefreshContactsModel() + ArbitraryRow.FIND_BY_PHONE_NUMBER -> FindByPhoneNumberModel() + ArbitraryRow.FIND_BY_USERNAME -> FindByUsernameModel() } } } @@ -128,5 +162,7 @@ class ContactSelectionListAdapter( fun onNewGroupClicked() fun onInviteToSignalClicked() fun onRefreshContactsClicked() + fun onFindByPhoneNumberClicked() + fun onFindByUsernameClicked() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java index b212fad57a..ab75168ac7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java @@ -76,6 +76,7 @@ import org.thoughtcrime.securesms.profiles.manage.UsernameRepository.UsernameAci import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.util.CommunicationActions; +import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.UsernameUtil; import org.thoughtcrime.securesms.util.ViewUtil; @@ -141,6 +142,7 @@ public final class ContactSelectionListFragment extends LoggingFragment { private ContactSearchMediator contactSearchMediator; @Nullable private NewConversationCallback newConversationCallback; + @Nullable private FindByCallback findByCallback; @Nullable private NewCallCallback newCallCallback; @Nullable private ScrollCallback scrollCallback; @Nullable private OnItemLongClickListener onItemLongClickListener; @@ -161,6 +163,10 @@ public final class ContactSelectionListFragment extends LoggingFragment { newConversationCallback = (NewConversationCallback) context; } + if (context instanceof FindByCallback) { + findByCallback = (FindByCallback) context; + } + if (context instanceof NewCallCallback) { newCallCallback = (NewCallCallback) context; } @@ -379,6 +385,16 @@ public final class ContactSelectionListFragment extends LoggingFragment { newConversationCallback.onNewGroup(false); } + @Override + public void onFindByPhoneNumberClicked() { + findByCallback.onFindByPhoneNumber(); + } + + @Override + public void onFindByUsernameClicked() { + findByCallback.onFindByUsername(); + } + @Override public void onInviteToSignalClicked() { if (newConversationCallback != null) { @@ -660,6 +676,10 @@ public final class ContactSelectionListFragment extends LoggingFragment { } } + public void addRecipientToSelectionIfAble(@NonNull RecipientId recipientId) { + listClickListener.onItemClick(new ContactSearchKey.RecipientSearchKey(recipientId, false)); + } + private class ListClickListener { public void onItemClick(ContactSearchKey contact) { boolean isUnknown = contact instanceof ContactSearchKey.UnknownRecipientKey; @@ -874,6 +894,11 @@ public final class ContactSelectionListFragment extends LoggingFragment { builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.NEW_GROUP.getCode()); } + if (findByCallback != null && FeatureFlags.usernames()) { + builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_BY_USERNAME.getCode()); + builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_BY_PHONE_NUMBER.getCode()); + } + if (transportType != null) { if (!hasQuery && includeRecents) { builder.addSection(new ContactSearchConfiguration.Section.Recents( @@ -891,7 +916,7 @@ public final class ContactSelectionListFragment extends LoggingFragment { builder.addSection(new ContactSearchConfiguration.Section.Individuals( includeSelf, transportType, - newCallCallback == null, + newCallCallback == null && findByCallback == null, null, !hideLetterHeaders() )); @@ -1011,6 +1036,12 @@ public final class ContactSelectionListFragment extends LoggingFragment { void onNewGroup(boolean forceV1); } + public interface FindByCallback { + void onFindByUsername(); + + void onFindByPhoneNumber(); + } + public interface NewCallCallback { void onInvite(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/NewConversationActivity.java b/app/src/main/java/org/thoughtcrime/securesms/NewConversationActivity.java index 98289b8c11..38cc30dabb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/NewConversationActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/NewConversationActivity.java @@ -50,6 +50,8 @@ import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.ui.findby.FindByActivity; +import org.thoughtcrime.securesms.recipients.ui.findby.FindByMode; import org.thoughtcrime.securesms.util.CommunicationActions; import org.thoughtcrime.securesms.util.views.SimpleProgressDialog; @@ -70,14 +72,15 @@ import io.reactivex.rxjava3.disposables.Disposable; * @author Moxie Marlinspike */ public class NewConversationActivity extends ContactSelectionActivity - implements ContactSelectionListFragment.NewConversationCallback, ContactSelectionListFragment.OnItemLongClickListener + implements ContactSelectionListFragment.NewConversationCallback, ContactSelectionListFragment.OnItemLongClickListener, ContactSelectionListFragment.FindByCallback { @SuppressWarnings("unused") private static final String TAG = Log.tag(NewConversationActivity.class); - private ContactsManagementViewModel viewModel; - private ActivityResultLauncher contactLauncher; + private ContactsManagementViewModel viewModel; + private ActivityResultLauncher contactLauncher; + private ActivityResultLauncher findByLauncher; private final LifecycleDisposable disposables = new LifecycleDisposable(); @@ -99,6 +102,12 @@ public class NewConversationActivity extends ContactSelectionActivity } }); + findByLauncher = registerForActivityResult(new FindByActivity.Contract(), result -> { + if (result != null) { + launch(result); + } + }); + viewModel = new ViewModelProvider(this, factory).get(ContactsManagementViewModel.class); } @@ -163,7 +172,12 @@ public class NewConversationActivity extends ContactSelectionActivity } private void launch(Recipient recipient) { - Disposable disposable = ConversationIntents.createBuilder(this, recipient.getId(), -1L) + launch(recipient.getId()); + } + + + private void launch(RecipientId recipientId) { + Disposable disposable = ConversationIntents.createBuilder(this, recipientId, -1L) .map(builder -> builder .withDraftText(getIntent().getStringExtra(Intent.EXTRA_TEXT)) .withDataUri(getIntent().getData()) @@ -234,6 +248,16 @@ public class NewConversationActivity extends ContactSelectionActivity finish(); } + @Override + public void onFindByUsername() { + findByLauncher.launch(FindByMode.USERNAME); + } + + @Override + public void onFindByPhoneNumber() { + findByLauncher.launch(FindByMode.PHONE_NUMBER); + } + @Override public boolean onLongClick(View anchorView, ContactSearchKey contactSearchKey, RecyclerView recyclerView) { RecipientId recipientId = contactSearchKey.requireRecipientSearchKey().getRecipientId(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ContactFilterView.java b/app/src/main/java/org/thoughtcrime/securesms/components/ContactFilterView.java index b776fda73e..d3266e0eed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ContactFilterView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ContactFilterView.java @@ -114,23 +114,23 @@ public final class ContactFilterView extends FrameLayout { int defStyle) { final TypedArray attributes = context.obtainStyledAttributes(attrs, - R.styleable.ContactFilterToolbar, + R.styleable.ContactFilterView, defStyle, 0); - int styleResource = attributes.getResourceId(R.styleable.ContactFilterToolbar_searchTextStyle, -1); + int styleResource = attributes.getResourceId(R.styleable.ContactFilterView_searchTextStyle, -1); if (styleResource != -1) { TextViewCompat.setTextAppearance(searchText, styleResource); } - if (!attributes.getBoolean(R.styleable.ContactFilterToolbar_showDialpad, true)) { + if (!attributes.getBoolean(R.styleable.ContactFilterView_showDialpad, true)) { dialpadToggle.setVisibility(GONE); } - if (attributes.getBoolean(R.styleable.ContactFilterToolbar_cfv_autoFocus, true)) { + if (attributes.getBoolean(R.styleable.ContactFilterView_cfv_autoFocus, true)) { searchText.requestFocus(); } - int backgroundRes = attributes.getResourceId(R.styleable.ContactFilterToolbar_cfv_background, -1); + int backgroundRes = attributes.getResourceId(R.styleable.ContactFilterView_cfv_background, -1); if (backgroundRes != -1) { findViewById(R.id.background_holder).setBackgroundResource(backgroundRes); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersActivity.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersActivity.java index 652e894bf2..4e639613ed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersActivity.java @@ -6,6 +6,7 @@ import android.os.Bundle; import android.view.View; import android.widget.Toast; +import androidx.activity.result.ActivityResultLauncher; import androidx.annotation.NonNull; import androidx.lifecycle.ViewModelProvider; @@ -20,20 +21,24 @@ import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.groups.SelectionLimits; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.ui.findby.FindByActivity; +import org.thoughtcrime.securesms.recipients.ui.findby.FindByMode; import org.thoughtcrime.securesms.util.Util; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.function.Consumer; -public class AddMembersActivity extends PushContactSelectionActivity { +public class AddMembersActivity extends PushContactSelectionActivity implements ContactSelectionListFragment.FindByCallback { public static final String GROUP_ID = "group_id"; public static final String ANNOUNCEMENT_GROUP = "announcement_group"; - private View done; - private AddMembersViewModel viewModel; + private View done; + private AddMembersViewModel viewModel; + private ActivityResultLauncher findByActivityLauncher; public static @NonNull Intent createIntent(@NonNull Context context, @NonNull GroupId groupId, @@ -70,6 +75,12 @@ public class AddMembersActivity extends PushContactSelectionActivity { ); disableDone(); + + findByActivityLauncher = registerForActivityResult(new FindByActivity.Contract(), result -> { + if (result != null) { + contactsFragment.addRecipientToSelectionIfAble(result); + } + }); } @Override @@ -119,6 +130,16 @@ public class AddMembersActivity extends PushContactSelectionActivity { } } + @Override + public void onFindByPhoneNumber() { + findByActivityLauncher.launch(FindByMode.PHONE_NUMBER); + } + + @Override + public void onFindByUsername() { + findByActivityLauncher.launch(FindByMode.USERNAME); + } + private void enableDone() { done.setEnabled(true); done.animate().alpha(1f); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupActivity.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupActivity.java index d01091d0c8..a88a359b84 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupActivity.java @@ -6,6 +6,7 @@ import android.os.Bundle; import android.view.MenuItem; import android.view.View; +import androidx.activity.result.ActivityResultLauncher; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -26,11 +27,14 @@ import org.thoughtcrime.securesms.groups.ui.creategroup.details.AddGroupDetailsA import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.ui.findby.FindByActivity; +import org.thoughtcrime.securesms.recipients.ui.findby.FindByMode; import org.thoughtcrime.securesms.util.FeatureFlags; import org.signal.core.util.Stopwatch; import org.thoughtcrime.securesms.util.views.SimpleProgressDialog; import java.io.IOException; +import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.Set; @@ -38,14 +42,16 @@ import java.util.concurrent.TimeUnit; import java.util.function.Consumer; import java.util.stream.Collectors; -public class CreateGroupActivity extends ContactSelectionActivity { +public class CreateGroupActivity extends ContactSelectionActivity implements ContactSelectionListFragment.FindByCallback { private static final String TAG = Log.tag(CreateGroupActivity.class); private static final short REQUEST_CODE_ADD_DETAILS = 17275; - private MaterialButton skip; - private FloatingActionButton next; + private MaterialButton skip; + private FloatingActionButton next; + private ActivityResultLauncher findByActivityLauncher; + public static Intent newIntent(@NonNull Context context) { Intent intent = new Intent(context, CreateGroupActivity.class); @@ -77,6 +83,12 @@ public class CreateGroupActivity extends ContactSelectionActivity { skip.setOnClickListener(v -> handleNextPressed()); next.setOnClickListener(v -> handleNextPressed()); + + findByActivityLauncher = registerForActivityResult(new FindByActivity.Contract(), result -> { + if (result != null) { + contactsFragment.addRecipientToSelectionIfAble(result); + } + }); } @Override @@ -131,6 +143,16 @@ public class CreateGroupActivity extends ContactSelectionActivity { } } + @Override + public void onFindByPhoneNumber() { + findByActivityLauncher.launch(FindByMode.PHONE_NUMBER); + } + + @Override + public void onFindByUsername() { + findByActivityLauncher.launch(FindByMode.USERNAME); + } + private void extendSkip() { skip.setVisibility(View.VISIBLE); next.setVisibility(View.GONE); diff --git a/app/src/main/java/org/thoughtcrime/securesms/phonenumbers/PhoneNumberVisualTransformation.kt b/app/src/main/java/org/thoughtcrime/securesms/phonenumbers/PhoneNumberVisualTransformation.kt new file mode 100644 index 0000000000..67ecb59dbb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/phonenumbers/PhoneNumberVisualTransformation.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.phonenumbers + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation +import com.google.i18n.phonenumbers.AsYouTypeFormatter +import com.google.i18n.phonenumbers.PhoneNumberUtil + +/** + * Formats the input number according to the regionCode. Assumes the input is all digits. + */ +class PhoneNumberVisualTransformation( + regionCode: String +) : VisualTransformation { + + private val asYouTypeFormatter: AsYouTypeFormatter = PhoneNumberUtil.getInstance().getAsYouTypeFormatter(regionCode) + + override fun filter(text: AnnotatedString): TransformedText { + asYouTypeFormatter.clear() + val output = text.map { asYouTypeFormatter.inputDigit(it) }.lastOrNull() ?: text.text + + return TransformedText( + AnnotatedString(output), + PhoneNumberOffsetMapping(output) + ) + } + + /** + * Each character in our phone number is either a digit or a transformed offset. + */ + private class PhoneNumberOffsetMapping( + private val transformed: String + ) : OffsetMapping { + override fun originalToTransformed(offset: Int): Int { + // We need a different algorithm here. We need to take UNTIL we've hit offset digits, and then return the resulting length. + var remaining = (offset + 1) + return transformed.takeWhile { + if (it.isDigit()) { + remaining-- + } + + remaining != 0 + }.length + } + + override fun transformedToOriginal(offset: Int): Int { + val substring = transformed.substring(0, offset) + val characterCount = substring.count { !it.isDigit() } + return offset - characterCount + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByActivity.kt new file mode 100644 index 0000000000..6936570bdd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByActivity.kt @@ -0,0 +1,558 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.recipients.ui.findby + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContract +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +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.input.KeyboardType +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.intl.Locale +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.lifecycleScope +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.dialog +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import kotlinx.coroutines.launch +import org.signal.core.ui.Buttons +import org.signal.core.ui.Dialogs +import org.signal.core.ui.Dividers +import org.signal.core.ui.Previews +import org.signal.core.ui.Scaffolds +import org.signal.core.ui.TextFields +import org.signal.core.ui.theme.SignalTheme +import org.signal.core.util.getParcelableExtraCompat +import org.thoughtcrime.securesms.PassphraseRequiredActivity +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.invites.InviteActions +import org.thoughtcrime.securesms.phonenumbers.PhoneNumberVisualTransformation +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.registration.util.CountryPrefix +import org.thoughtcrime.securesms.util.viewModel +import org.whispersystems.signalservice.api.util.PhoneNumberFormatter + +/** + * Allows the user to look up another Signal user by phone number or username and + * retrieve a RecipientId for that data. + */ +class FindByActivity : PassphraseRequiredActivity() { + + companion object { + private const val MODE = "FindByActivity.mode" + private const val RECIPIENT_ID = "FindByActivity.recipientId" + } + + private val viewModel: FindByViewModel by viewModel { + FindByViewModel(FindByMode.valueOf(intent.getStringExtra(MODE)!!)) + } + + override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { + setContent { + val state by viewModel.state + + val navController = rememberNavController() + + SignalTheme { + NavHost( + navController = navController, + startDestination = "find-by-content", + enterTransition = { slideInHorizontally(initialOffsetX = { it }) }, + exitTransition = { slideOutHorizontally(targetOffsetX = { -it }) }, + popEnterTransition = { slideInHorizontally(initialOffsetX = { -it }) }, + popExitTransition = { slideOutHorizontally(targetOffsetX = { it }) } + ) { + composable("find-by-content") { + val title = remember(state.mode) { + if (state.mode == FindByMode.USERNAME) R.string.FindByActivity__find_by_username else R.string.FindByActivity__find_by_phone_number + } + + Scaffolds.Settings( + title = stringResource(id = title), + onNavigationClick = { finishAfterTransition() }, + navigationIconPainter = painterResource(id = R.drawable.symbol_arrow_left_24) + ) { + val context = LocalContext.current + FindByContent( + paddingValues = it, + state = state, + onUserEntryChanged = viewModel::onUserEntryChanged, + onNextClick = { + lifecycleScope.launch { + when (val result = viewModel.onNextClicked(context)) { + is FindByResult.Success -> { + setResult(RESULT_OK, Intent().putExtra(RECIPIENT_ID, result.recipientId)) + finishAfterTransition() + } + + FindByResult.InvalidEntry -> navController.navigate("invalid-entry") + is FindByResult.NotFound -> navController.navigate("not-found/${result.recipientId.toLong()}") + } + } + }, + onSelectCountryPrefixClick = { + navController.navigate("select-country-prefix") + } + ) + } + } + + composable("select-country-prefix") { + Scaffolds.Settings( + title = stringResource(id = R.string.FindByActivity__select_country_code), + onNavigationClick = { navController.popBackStack() }, + navigationIconPainter = painterResource(id = R.drawable.symbol_arrow_left_24) + ) { paddingValues -> + SelectCountryScreen( + paddingValues = paddingValues, + searchEntry = state.countryPrefixSearchEntry, + onSearchEntryChanged = viewModel::onCountryPrefixSearchEntryChanged, + supportedCountryPrefixes = state.supportedCountryPrefixes, + onCountryPrefixSelected = { + navController.popBackStack() + viewModel.onCountryPrefixSelected(it) + viewModel.onCountryPrefixSearchEntryChanged("") + } + ) + } + } + + dialog("invalid-entry") { + val title = if (state.mode == FindByMode.USERNAME) { + stringResource(id = R.string.FindByActivity__invalid_username) + } else { + stringResource(id = R.string.FindByActivity__invalid_phone_number) + } + + val body = if (state.mode == FindByMode.USERNAME) { + stringResource(id = R.string.FindByActivity__s_is_not_a_valid_username, state.userEntry) + } else { + val formattedNumber = remember(state.userEntry) { + val cleansed = state.userEntry.removePrefix(state.selectedCountryPrefix.digits.toString()) + PhoneNumberFormatter.formatE164(state.selectedCountryPrefix.digits.toString(), cleansed) + } + stringResource(id = R.string.FindByActivity__s_is_not_a_valid_phone_number, formattedNumber) + } + + Dialogs.SimpleAlertDialog( + title = title, + body = body, + confirm = stringResource(id = android.R.string.ok), + onConfirm = {}, + onDismiss = { navController.popBackStack() } + ) + } + + dialog( + route = "not-found/{recipientId}", + arguments = listOf(navArgument("recipientId") { type = NavType.LongType }) + ) { navBackStackEntry -> + val title = if (state.mode == FindByMode.USERNAME) { + stringResource(id = R.string.FindByActivity__username_not_found) + } else { + stringResource(id = R.string.FindByActivity__invite_to_signal) + } + + val body = if (state.mode == FindByMode.USERNAME) { + stringResource(id = R.string.FindByActivity__s_is_not_a_signal_user, state.userEntry) + } else { + val formattedNumber = remember(state.userEntry) { + val cleansed = state.userEntry.removePrefix(state.selectedCountryPrefix.digits.toString()) + PhoneNumberFormatter.formatE164(state.selectedCountryPrefix.digits.toString(), cleansed) + } + stringResource(id = R.string.FindByActivity__s_is_not_a_signal_user_would, formattedNumber) + } + + val confirm = if (state.mode == FindByMode.USERNAME) { + stringResource(id = android.R.string.ok) + } else { + stringResource(id = R.string.FindByActivity__invite) + } + + val dismiss = if (state.mode == FindByMode.USERNAME) { + Dialogs.NoDismiss + } else { + stringResource(id = android.R.string.cancel) + } + + val context = LocalContext.current + Dialogs.SimpleAlertDialog( + title = title, + body = body, + confirm = confirm, + dismiss = dismiss, + onConfirm = { + if (state.mode == FindByMode.PHONE_NUMBER) { + val recipientId = navBackStackEntry.arguments?.getLong("recipientId")?.takeIf { it > 0 }?.let { RecipientId.from(it) } ?: RecipientId.UNKNOWN + if (recipientId != RecipientId.UNKNOWN) { + InviteActions.inviteUserToSignal( + context, + Recipient.resolved(recipientId), + null, + this@FindByActivity::startActivity + ) + } + } + }, + onDismiss = { navController.popBackStack() } + ) + } + } + } + } + } + + class Contract : ActivityResultContract() { + override fun createIntent(context: Context, input: FindByMode): Intent { + return Intent(context, FindByActivity::class.java) + .putExtra(MODE, input.name) + } + + override fun parseResult(resultCode: Int, intent: Intent?): RecipientId? { + return intent?.getParcelableExtraCompat(RECIPIENT_ID, RecipientId::class.java) + } + } +} + +@Preview +@Composable +private fun FindByContentPreview() { + Previews.Preview { + FindByContent( + paddingValues = PaddingValues(0.dp), + state = FindByState( + mode = FindByMode.PHONE_NUMBER, + userEntry = "" + ), + onUserEntryChanged = {}, + onNextClick = {}, + onSelectCountryPrefixClick = {} + ) + } +} + +@Composable +private fun FindByContent( + paddingValues: PaddingValues, + state: FindByState, + onUserEntryChanged: (String) -> Unit, + onNextClick: () -> Unit, + onSelectCountryPrefixClick: () -> Unit +) { + val placeholderLabel = remember(state.mode) { + if (state.mode == FindByMode.PHONE_NUMBER) R.string.FindByActivity__phone_number else R.string.FindByActivity__username + } + + val focusRequester = remember { + FocusRequester() + } + + val keyboardType = remember(state.mode) { + if (state.mode == FindByMode.PHONE_NUMBER) { + KeyboardType.Phone + } else { + KeyboardType.Text + } + } + + Column( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + ) { + val onNextAction = remember(state.isLookupInProgress) { + KeyboardActions(onNext = { + if (!state.isLookupInProgress) { + onNextClick() + } + }) + } + + val visualTransformation = if (state.mode == FindByMode.USERNAME) { + VisualTransformation.None + } else { + remember(state.selectedCountryPrefix) { + PhoneNumberVisualTransformation(state.selectedCountryPrefix.regionCode) + } + } + + TextFields.TextField( + enabled = !state.isLookupInProgress, + value = state.userEntry, + onValueChange = onUserEntryChanged, + singleLine = true, + placeholder = { Text(text = stringResource(id = placeholderLabel)) }, + prefix = if (state.mode == FindByMode.USERNAME) { + null + } else { + { + PhoneNumberEntryPrefix( + enabled = !state.isLookupInProgress, + selectedCountryPrefix = state.selectedCountryPrefix, + onSelectCountryPrefixClick = onSelectCountryPrefixClick + ) + } + }, + visualTransformation = visualTransformation, + keyboardOptions = KeyboardOptions( + keyboardType = keyboardType, + imeAction = ImeAction.Next + ), + shape = RoundedCornerShape(32.dp), + colors = TextFieldDefaults.colors( + unfocusedIndicatorColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + errorIndicatorColor = Color.Transparent, + cursorColor = MaterialTheme.colorScheme.onSurface + ), + keyboardActions = onNextAction, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 10.dp) + .focusRequester(focusRequester) + .heightIn(min = 44.dp), + contentPadding = TextFieldDefaults.contentPaddingWithoutLabel(top = 10.dp, bottom = 10.dp) + ) + + if (state.mode == FindByMode.USERNAME) { + Text( + text = stringResource(id = R.string.FindByActivity__enter_a_full_username), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter)) + .padding(top = 8.dp) + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + Box( + contentAlignment = Alignment.BottomEnd, + modifier = Modifier.fillMaxWidth() + ) { + Buttons.LargeTonal( + enabled = !state.isLookupInProgress, + onClick = onNextClick, + contentPadding = PaddingValues(0.dp), + modifier = Modifier + .padding(16.dp) + .size(48.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.symbol_arrow_right_24), + contentDescription = stringResource(id = R.string.FindByActivity__next) + ) + } + } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + } +} + +@Composable +private fun PhoneNumberEntryPrefix( + enabled: Boolean, + selectedCountryPrefix: CountryPrefix, + onSelectCountryPrefixClick: () -> Unit +) { + Row( + modifier = Modifier.padding(end = 16.dp) + ) { + Row( + modifier = Modifier.clickable(onClick = onSelectCountryPrefixClick, enabled = enabled) + ) { + Text( + text = selectedCountryPrefix.toString() + ) + Icon( + painter = painterResource(id = R.drawable.symbol_dropdown_triangle_24), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Dividers.Vertical( + thickness = 1.dp, + color = MaterialTheme.colorScheme.outline, + modifier = Modifier + .padding(vertical = 2.dp) + .padding(start = 8.dp) + .height(20.dp) + ) + } +} + +@Preview +@Composable +private fun SelectCountryScreenPreview() { + Previews.Preview { + SelectCountryScreen( + paddingValues = PaddingValues(0.dp), + searchEntry = "", + onSearchEntryChanged = {}, + supportedCountryPrefixes = FindByState(mode = FindByMode.PHONE_NUMBER).supportedCountryPrefixes, + onCountryPrefixSelected = {} + ) + } +} + +@Composable +private fun SelectCountryScreen( + paddingValues: PaddingValues, + searchEntry: String, + onSearchEntryChanged: (String) -> Unit, + onCountryPrefixSelected: (CountryPrefix) -> Unit, + supportedCountryPrefixes: List +) { + val focusRequester = remember { + FocusRequester() + } + + Column( + modifier = Modifier.padding(paddingValues) + ) { + TextFields.TextField( + value = searchEntry, + onValueChange = onSearchEntryChanged, + placeholder = { Text(text = stringResource(id = R.string.FindByActivity__search)) }, + shape = RoundedCornerShape(32.dp), + colors = TextFieldDefaults.colors( + unfocusedIndicatorColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + errorIndicatorColor = Color.Transparent, + cursorColor = MaterialTheme.colorScheme.onSurface + ), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 10.dp) + .focusRequester(focusRequester) + .heightIn(min = 44.dp), + contentPadding = TextFieldDefaults.contentPaddingWithoutLabel(top = 10.dp, bottom = 10.dp) + ) + + LazyColumn { + items( + items = supportedCountryPrefixes + ) { + CountryPrefixRowItem( + searchTerm = searchEntry, + countryPrefix = it, + onClick = { onCountryPrefixSelected(it) } + ) + } + } + } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } +} + +@Composable +private fun CountryPrefixRowItem( + searchTerm: String, + countryPrefix: CountryPrefix, + onClick: () -> Unit +) { + val regionDisplayName = remember(countryPrefix.regionCode, Locale.current) { + PhoneNumberFormatter.getRegionDisplayName(countryPrefix.regionCode).orElse(countryPrefix.regionCode) + } + + if (searchTerm.isNotBlank() && !regionDisplayName.contains(searchTerm, ignoreCase = true)) { + return + } + + val highlightedName: AnnotatedString = remember(regionDisplayName, searchTerm) { + if (searchTerm.isBlank()) { + AnnotatedString(regionDisplayName) + } else { + buildAnnotatedString { + append(regionDisplayName) + + val startIndex = regionDisplayName.indexOf(searchTerm, ignoreCase = true) + + addStyle( + style = SpanStyle( + fontWeight = FontWeight.Bold + ), + start = startIndex, + end = startIndex + searchTerm.length + ) + } + } + } + + Column( + verticalArrangement = spacedBy((-2).dp), + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter)) + .padding(top = 16.dp, bottom = 14.dp) + ) { + Text( + text = highlightedName + ) + + Text( + text = countryPrefix.toString(), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByMode.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByMode.kt new file mode 100644 index 0000000000..bac2e138ae --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByMode.kt @@ -0,0 +1,11 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.recipients.ui.findby + +enum class FindByMode { + PHONE_NUMBER, + USERNAME +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByResult.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByResult.kt new file mode 100644 index 0000000000..f845a54629 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByResult.kt @@ -0,0 +1,14 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.recipients.ui.findby + +import org.thoughtcrime.securesms.recipients.RecipientId + +sealed interface FindByResult { + data class Success(val recipientId: RecipientId) : FindByResult + object InvalidEntry : FindByResult + data class NotFound(val recipientId: RecipientId = RecipientId.UNKNOWN) : FindByResult +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByState.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByState.kt new file mode 100644 index 0000000000..de79b9f213 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByState.kt @@ -0,0 +1,20 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.recipients.ui.findby + +import com.google.i18n.phonenumbers.PhoneNumberUtil +import org.thoughtcrime.securesms.registration.util.CountryPrefix + +data class FindByState( + val mode: FindByMode, + val userEntry: String = "", + val supportedCountryPrefixes: List = PhoneNumberUtil.getInstance().supportedCallingCodes + .map { CountryPrefix(it, PhoneNumberUtil.getInstance().getRegionCodeForCountryCode(it)) } + .sortedBy { it.digits.toString() }, + val selectedCountryPrefix: CountryPrefix = supportedCountryPrefixes.first(), + val countryPrefixSearchEntry: String = "", + val isLookupInProgress: Boolean = false +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByViewModel.kt new file mode 100644 index 0000000000..8ed177d703 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByViewModel.kt @@ -0,0 +1,118 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.recipients.ui.findby + +import android.content.Context +import androidx.annotation.WorkerThread +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import org.signal.core.util.concurrent.safeBlockingGet +import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery +import org.thoughtcrime.securesms.phonenumbers.NumberUtil +import org.thoughtcrime.securesms.profiles.manage.UsernameRepository +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.registration.util.CountryPrefix +import org.thoughtcrime.securesms.util.UsernameUtil +import java.util.concurrent.TimeUnit + +class FindByViewModel( + mode: FindByMode +) : ViewModel() { + + private val internalState = mutableStateOf( + FindByState( + mode = mode + ) + ) + + val state: State = internalState + + fun onUserEntryChanged(userEntry: String) { + val cleansed = if (state.value.mode == FindByMode.PHONE_NUMBER) { + userEntry.filter { it.isDigit() } + } else { + userEntry + } + + internalState.value = state.value.copy(userEntry = cleansed) + } + + fun onCountryPrefixSearchEntryChanged(searchEntry: String) { + internalState.value = state.value.copy(countryPrefixSearchEntry = searchEntry) + } + + fun onCountryPrefixSelected(countryPrefix: CountryPrefix) { + internalState.value = state.value.copy(selectedCountryPrefix = countryPrefix) + } + + suspend fun onNextClicked(context: Context): FindByResult { + internalState.value = state.value.copy(isLookupInProgress = true) + val findByResult = viewModelScope.async(context = Dispatchers.IO) { + if (state.value.mode == FindByMode.USERNAME) { + performUsernameLookup() + } else { + performPhoneLookup(context) + } + }.await() + + internalState.value = state.value.copy(isLookupInProgress = false) + return findByResult + } + + @WorkerThread + private fun performUsernameLookup(): FindByResult { + val username = state.value.userEntry + + if (!UsernameUtil.isValidUsernameForSearch(username)) { + return FindByResult.InvalidEntry + } + + return when (val result = UsernameRepository.fetchAciForUsername(username = username).safeBlockingGet()) { + UsernameRepository.UsernameAciFetchResult.NetworkError -> FindByResult.NotFound() + UsernameRepository.UsernameAciFetchResult.NotFound -> FindByResult.NotFound() + is UsernameRepository.UsernameAciFetchResult.Success -> FindByResult.Success(Recipient.externalUsername(result.aci, username).id) + } + } + + @WorkerThread + private fun performPhoneLookup(context: Context): FindByResult { + val stateSnapshot = state.value + val countryCode = stateSnapshot.selectedCountryPrefix.digits + val nationalNumber = stateSnapshot.userEntry.removePrefix(countryCode.toString()) + + val e164 = "$countryCode$nationalNumber" + + if (!NumberUtil.isVisuallyValidNumber(e164)) { + return FindByResult.InvalidEntry + } + + val recipient = try { + Recipient.external(context, e164) + } catch (e: Exception) { + return FindByResult.InvalidEntry + } + + return if (!recipient.isRegistered || !recipient.hasServiceId()) { + try { + ContactDiscovery.refresh(context, recipient, false, TimeUnit.SECONDS.toMillis(10)) + val resolved = Recipient.resolved(recipient.id) + if (!resolved.isRegistered) { + FindByResult.NotFound(recipient.id) + } else { + FindByResult.Success(recipient.id) + } + } catch (e: Exception) { + FindByResult.NotFound(recipient.id) + } + } else { + FindByResult.Success(recipient.id) + } + } +} diff --git a/app/src/main/res/drawable/symbol_dropdown_triangle_24.xml b/app/src/main/res/drawable/symbol_dropdown_triangle_24.xml new file mode 100644 index 0000000000..61d3abb2b7 --- /dev/null +++ b/app/src/main/res/drawable/symbol_dropdown_triangle_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/symbol_number_24.xml b/app/src/main/res/drawable/symbol_number_24.xml new file mode 100644 index 0000000000..1c4c48b212 --- /dev/null +++ b/app/src/main/res/drawable/symbol_number_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/add_members_activity.xml b/app/src/main/res/layout/add_members_activity.xml index e1c893ed43..1a3694b95d 100644 --- a/app/src/main/res/layout/add_members_activity.xml +++ b/app/src/main/res/layout/add_members_activity.xml @@ -1,10 +1,10 @@ + android:layout_height="match_parent" + tools:viewBindingIgnore="true"> + app:title="@string/AddMembersActivity__add_members" + app:titleTextAppearance="@style/Signal.Text.Title" /> + android:layout_marginBottom="12dp" + android:minHeight="44dp" + app:cfv_autoFocus="false" /> + android:orientation="vertical" + tools:viewBindingIgnore="true"> + android:minHeight="44sp" + app:cfv_autoFocus="false" /> + + + + + + diff --git a/app/src/main/res/layout/contact_selection_find_by_username_item.xml b/app/src/main/res/layout/contact_selection_find_by_username_item.xml new file mode 100644 index 0000000000..7ea90b9451 --- /dev/null +++ b/app/src/main/res/layout/contact_selection_find_by_username_item.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/app/src/main/res/layout/create_group_activity.xml b/app/src/main/res/layout/create_group_activity.xml index 71dbef2662..f22de9cad2 100644 --- a/app/src/main/res/layout/create_group_activity.xml +++ b/app/src/main/res/layout/create_group_activity.xml @@ -1,12 +1,12 @@ + android:orientation="vertical" + tools:viewBindingIgnore="true"> - + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 028b1e22fd..010109af60 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2570,6 +2570,10 @@ %1$d member %1$d members + + Find by phone number + + Find by username Signal needs access to your contacts in order to display them. @@ -6452,5 +6456,41 @@ Not now + + + Find by username + + Find by phone number + + Select country code + + Username + + Phone number + + Enter a full username with its pair of digits. + + Next + + Search + + Invalid username + + Invalid phone number + + Invite to Signal + + Username not found + + %1$s is not a valid username. Make sure you\'ve entered the complete username followed by its set of digits. + + %1$s is not valid phone number. Try again with a valid phone number + + %1$s is not a Signal user. Please check the username and try again. + + %1$s is not a Signal user. Would you like to invite this number? + + Invite + diff --git a/app/src/test/java/org/thoughtcrime/securesms/phonenumbers/PhoneNumberVisualTransformationTest.kt b/app/src/test/java/org/thoughtcrime/securesms/phonenumbers/PhoneNumberVisualTransformationTest.kt new file mode 100644 index 0000000000..cb94089a6f --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/phonenumbers/PhoneNumberVisualTransformationTest.kt @@ -0,0 +1,88 @@ +package org.thoughtcrime.securesms.phonenumbers + +import androidx.compose.ui.text.AnnotatedString +import org.junit.Assert +import org.junit.Test + +class PhoneNumberVisualTransformationTest { + + @Test + fun `given US region, when I enter 5550123, then I expect 555-0123`() { + val regionCode = "US" + val transformation = PhoneNumberVisualTransformation(regionCode) + val given = "5550123" + val expected = "555-0123" + val output = transformation.filter(AnnotatedString(given)) + Assert.assertEquals(output.text.text, expected) + } + + @Test + fun `given US region, when I enter 555012, then I expect 555-012`() { + val regionCode = "US" + val transformation = PhoneNumberVisualTransformation(regionCode) + val given = "555012" + val expected = "555-012" + val output = transformation.filter(AnnotatedString(given)) + Assert.assertEquals(output.text.text, expected) + } + + @Test + fun `given US region formatted number, when I originalToTransformed index 0, then I expect index 0`() { + val regionCode = "US" + val transformation = PhoneNumberVisualTransformation(regionCode) + val given = "5550123" + val output = transformation.filter(AnnotatedString(given)) + val mapping = output.offsetMapping + + val result = mapping.originalToTransformed(0) + Assert.assertEquals(0, result) + } + + @Test + fun `given US region formatted number, when I originalToTransformed index 6, then I expect index 7`() { + val regionCode = "US" + val transformation = PhoneNumberVisualTransformation(regionCode) + val given = "5550123" + val output = transformation.filter(AnnotatedString(given)) + val mapping = output.offsetMapping + + val result = mapping.originalToTransformed(6) + Assert.assertEquals(7, result) + } + + @Test + fun `given US region formatted number, when I transformedToOriginal index 0, then I expect index 0`() { + val regionCode = "US" + val transformation = PhoneNumberVisualTransformation(regionCode) + val given = "5550123" + val output = transformation.filter(AnnotatedString(given)) + val mapping = output.offsetMapping + + val result = mapping.transformedToOriginal(0) + Assert.assertEquals(0, result) + } + + @Test + fun `given US region formatted number, when I transformedToOriginal index 7, then I expect index 6`() { + val regionCode = "US" + val transformation = PhoneNumberVisualTransformation(regionCode) + val given = "5550123" + val output = transformation.filter(AnnotatedString(given)) + val mapping = output.offsetMapping + + val result = mapping.transformedToOriginal(7) + Assert.assertEquals(6, result) + } + + @Test + fun `given US region formatted number with local code, when I originalToTransformed index 7, then I expect index 11`() { + val regionCode = "US" + val transformation = PhoneNumberVisualTransformation(regionCode) + val given = "55501233" + val output = transformation.filter(AnnotatedString(given)) + val mapping = output.offsetMapping + + val result = mapping.originalToTransformed(7) + Assert.assertEquals(11, result) + } +} diff --git a/core-ui/src/main/java/org/signal/core/ui/Dialogs.kt b/core-ui/src/main/java/org/signal/core/ui/Dialogs.kt index f6a096e649..2defa929d0 100644 --- a/core-ui/src/main/java/org/signal/core/ui/Dialogs.kt +++ b/core-ui/src/main/java/org/signal/core/ui/Dialogs.kt @@ -17,6 +17,8 @@ import org.signal.core.ui.Dialogs.SimpleMessageDialog object Dialogs { + const val NoDismiss = "" + @Composable fun SimpleMessageDialog( message: String, @@ -48,10 +50,10 @@ object Dialogs { title: String, body: String, confirm: String, - dismiss: String, onConfirm: () -> Unit, onDismiss: () -> Unit, modifier: Modifier = Modifier, + dismiss: String = NoDismiss, confirmColor: Color = Color.Unspecified, dismissColor: Color = Color.Unspecified, properties: DialogProperties = DialogProperties() @@ -68,10 +70,14 @@ object Dialogs { Text(text = confirm, color = confirmColor) } }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(text = dismiss, color = dismissColor) + dismissButton = if (dismiss.isNotEmpty()) { + { + TextButton(onClick = onDismiss) { + Text(text = dismiss, color = dismissColor) + } } + } else { + null }, modifier = modifier, properties = properties diff --git a/core-ui/src/main/java/org/signal/core/ui/Dividers.kt b/core-ui/src/main/java/org/signal/core/ui/Dividers.kt index a64df564d9..d489ed6fff 100644 --- a/core-ui/src/main/java/org/signal/core/ui/Dividers.kt +++ b/core-ui/src/main/java/org/signal/core/ui/Dividers.kt @@ -1,11 +1,18 @@ package org.signal.core.ui +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.material3.Divider import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import org.signal.core.ui.theme.SignalTheme @@ -21,6 +28,24 @@ object Dividers { modifier = modifier.padding(vertical = 16.25.dp) ) } + + @Composable + fun Vertical( + modifier: Modifier = Modifier, + thickness: Dp = 1.5.dp, + color: Color = MaterialTheme.colorScheme.surfaceVariant + ) { + val targetThickness = if (thickness == Dp.Hairline) { + (1f / LocalDensity.current.density).dp + } else { + thickness + } + Box( + modifier + .width(targetThickness) + .background(color = color) + ) + } } @Preview @@ -30,3 +55,11 @@ private fun DefaultPreview() { Dividers.Default() } } + +@Preview +@Composable +private fun VerticalPreview() { + SignalTheme(isDarkMode = false) { + Dividers.Vertical(modifier = Modifier.height(20.dp)) + } +} diff --git a/core-ui/src/main/java/org/signal/core/ui/TextFields.kt b/core-ui/src/main/java/org/signal/core/ui/TextFields.kt new file mode 100644 index 0000000000..c889d0298f --- /dev/null +++ b/core-ui/src/main/java/org/signal/core/ui/TextFields.kt @@ -0,0 +1,124 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.core.ui + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.selection.LocalTextSelectionColors +import androidx.compose.foundation.text.selection.TextSelectionColors +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.TextFieldColors +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.takeOrElse +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.VisualTransformation + +object TextFields { + + /** + * This is intended to replicate what TextField exposes but allows us to set our own content padding. + * Prefer the base TextField where possible. + */ + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun TextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + readOnly: Boolean = false, + textStyle: TextStyle = LocalTextStyle.current, + label: @Composable (() -> Unit)? = null, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + prefix: @Composable (() -> Unit)? = null, + suffix: @Composable (() -> Unit)? = null, + supportingText: @Composable (() -> Unit)? = null, + isError: Boolean = false, + visualTransformation: VisualTransformation = VisualTransformation.None, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + singleLine: Boolean = false, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + minLines: Int = 1, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + shape: Shape = TextFieldDefaults.shape, + colors: TextFieldColors = TextFieldDefaults.colors(), + contentPadding: PaddingValues = + if (label == null) { + TextFieldDefaults.contentPaddingWithoutLabel() + } else { + TextFieldDefaults.contentPaddingWithLabel() + } + ) { + // If color is not provided via the text style, use content color as a default + val textColor = textStyle.color.takeOrElse { + LocalContentColor.current + } + val mergedTextStyle = textStyle.merge(TextStyle(color = textColor)) + val cursorColor = rememberUpdatedState(newValue = if (isError) MaterialTheme.colorScheme.error else textColor) + + CompositionLocalProvider(LocalTextSelectionColors provides TextSelectionColors(handleColor = LocalContentColor.current, LocalContentColor.current.copy(alpha = 0.4f))) { + BasicTextField( + value = value, + modifier = modifier + .defaultMinSize( + minWidth = TextFieldDefaults.MinWidth, + minHeight = TextFieldDefaults.MinHeight + ), + onValueChange = onValueChange, + enabled = enabled, + readOnly = readOnly, + textStyle = mergedTextStyle, + cursorBrush = SolidColor(cursorColor.value), + visualTransformation = visualTransformation, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + interactionSource = interactionSource, + singleLine = singleLine, + maxLines = maxLines, + minLines = minLines, + decorationBox = @Composable { innerTextField -> + // places leading icon, text field with label and placeholder, trailing icon + TextFieldDefaults.DecorationBox( + value = value, + visualTransformation = visualTransformation, + innerTextField = innerTextField, + placeholder = placeholder, + label = label, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + prefix = prefix, + suffix = suffix, + supportingText = supportingText, + shape = shape, + singleLine = singleLine, + enabled = enabled, + isError = isError, + interactionSource = interactionSource, + colors = colors, + contentPadding = contentPadding + ) + } + ) + } + } +}