Add Find By Username and Find By Phone Number interstitials.

Co-authored-by: Greyson Parrelli <greyson@signal.org>
This commit is contained in:
Alex Hart
2024-02-01 17:59:20 -04:00
committed by Greyson Parrelli
parent ca3d239ce2
commit 700fe5e463
28 changed files with 1357 additions and 37 deletions

View File

@@ -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() {

View File

@@ -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<NewGroupModel> {
@@ -44,6 +46,16 @@ class ContactSelectionListAdapter(
override fun areContentsTheSame(newItem: RefreshContactsModel): Boolean = true
}
class FindByUsernameModel : MappingModel<FindByUsernameModel> {
override fun areItemsTheSame(newItem: FindByUsernameModel): Boolean = true
override fun areContentsTheSame(newItem: FindByUsernameModel): Boolean = true
}
class FindByPhoneNumberModel : MappingModel<FindByPhoneNumberModel> {
override fun areItemsTheSame(newItem: FindByPhoneNumberModel): Boolean = true
override fun areContentsTheSame(newItem: FindByPhoneNumberModel): Boolean = true
}
class MoreHeaderModel : MappingModel<MoreHeaderModel> {
override fun areItemsTheSame(newItem: MoreHeaderModel): Boolean = true
@@ -92,13 +104,33 @@ class ContactSelectionListAdapter(
}
}
private class FindByPhoneNumberViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<FindByPhoneNumberModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: FindByPhoneNumberModel) = Unit
}
private class FindByUsernameViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<FindByUsernameModel>(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()
}
}

View File

@@ -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();
}

View File

@@ -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<Intent> contactLauncher;
private ContactsManagementViewModel viewModel;
private ActivityResultLauncher<Intent> contactLauncher;
private ActivityResultLauncher<FindByMode> 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();

View File

@@ -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);
}

View File

@@ -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<FindByMode> 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);

View File

@@ -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<FindByMode> 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);

View File

@@ -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
}
}
}

View File

@@ -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<FindByMode, RecipientId?>() {
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<CountryPrefix>
) {
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
)
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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<CountryPrefix> = 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
)

View File

@@ -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<FindByState> = 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)
}
}
}