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
+ )
+ }
+ )
+ }
+ }
+}