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

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

View File

@@ -682,7 +682,12 @@
<activity android:name=".NewConversationActivity"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:windowSoftInputMode="stateAlwaysVisible"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"/>
<activity android:name=".recipients.ui.findby.FindByActivity"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:windowSoftInputMode="adjustResize"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"/>

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

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="18dp"
android:height="24dp"
android:viewportWidth="18"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M13.5 11.2c0.63-0.62 0.18-1.7-0.7-1.7H5.2c-0.88 0-1.33 1.08-0.7 1.7l3.44 3.45c0.59 0.58 1.53 0.58 2.12 0l3.44-3.44Z"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M11.35 4.19c0.1-0.48-0.2-0.94-0.66-1.04-0.48-0.1-0.94 0.2-1.04 0.66L8.8 7.62H5.5c-0.48 0-0.88 0.4-0.88 0.88s0.4 0.88 0.88 0.88h2.93l-1.15 5.24H4c-0.48 0-0.88 0.4-0.88 0.88s0.4 0.88 0.88 0.88h2.9L6.15 19.8c-0.1 0.48 0.2 0.94 0.66 1.04 0.48 0.1 0.94-0.2 1.04-0.66l0.84-3.82h4.7l-0.74 3.44c-0.1 0.48 0.2 0.94 0.66 1.04 0.48 0.1 0.94-0.2 1.04-0.66l0.84-3.82h3.31c0.48 0 0.88-0.39 0.88-0.87s-0.4-0.88-0.88-0.88h-2.93l1.15-5.24H20c0.48 0 0.88-0.4 0.88-0.88S20.48 7.62 20 7.62h-2.9l0.75-3.43c0.1-0.48-0.2-0.94-0.66-1.04-0.48-0.1-0.94 0.2-1.04 0.66L15.3 7.62h-4.7l0.74-3.43Zm3.58 5.18l-1.15 5.26h-4.7l1.14-5.26h4.7Z"/>
</vector>

View File

@@ -1,10 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:viewBindingIgnore="true"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_height="match_parent"
tools:viewBindingIgnore="true">
<LinearLayout
android:layout_width="fill_parent"
@@ -21,8 +21,8 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:navigationIcon="@drawable/ic_arrow_left_24"
app:titleTextAppearance="@style/Signal.Text.Title"
app:title="@string/AddMembersActivity__add_members" />
app:title="@string/AddMembersActivity__add_members"
app:titleTextAppearance="@style/Signal.Text.Title" />
<org.thoughtcrime.securesms.components.ContactFilterView
android:id="@+id/contact_filter_edit_text"
@@ -30,9 +30,10 @@
android:layout_height="wrap_content"
android:layout_marginLeft="@dimen/dsl_settings_gutter"
android:layout_marginTop="4dp"
android:layout_marginBottom="12dp"
android:layout_marginRight="@dimen/dsl_settings_gutter"
android:minHeight="44dp" />
android:layout_marginBottom="12dp"
android:minHeight="44dp"
app:cfv_autoFocus="false" />
<fragment
android:id="@+id/contact_selection_list_fragment"

View File

@@ -1,12 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:viewBindingIgnore="true"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="fill_parent"
android:layout_height="fill_parent"
android:layout_gravity="center"
android:orientation="vertical">
android:orientation="vertical"
tools:viewBindingIgnore="true">
<org.thoughtcrime.securesms.util.views.DarkOverflowToolbar
android:id="@+id/toolbar"
@@ -30,7 +30,8 @@
android:layout_marginTop="10dp"
android:layout_marginRight="16dp"
android:layout_marginBottom="10dp"
android:minHeight="44sp" />
android:minHeight="44sp"
app:cfv_autoFocus="false" />
<fragment
android:id="@+id/contact_selection_list_fragment"

View File

@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/rounded_inset_ripple_background"
android:minHeight="@dimen/contact_selection_item_height"
android:paddingStart="@dimen/dsl_settings_gutter"
android:paddingEnd="@dimen/dsl_settings_gutter"
tools:viewBindingIgnore="true">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/invite_image"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="@color/signal_colorSurfaceVariant"
android:importantForAccessibility="no"
app:contentPadding="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Signal.Circle"
app:srcCompat="@drawable/symbol_number_24"
app:tint="@color/signal_colorOnSurfaceVariant" />
<TextView
android:id="@+id/invite_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:ellipsize="marquee"
android:labelFor="@id/action_icon"
android:singleLine="true"
android:text="@string/ContactSelectionListFragment__find_by_phone_number"
android:textAppearance="@style/Signal.Text.BodyLarge"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/invite_image"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/rounded_inset_ripple_background"
android:minHeight="@dimen/contact_selection_item_height"
android:paddingStart="@dimen/dsl_settings_gutter"
android:paddingEnd="@dimen/dsl_settings_gutter"
tools:viewBindingIgnore="true">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/invite_image"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="@color/signal_colorSurfaceVariant"
android:importantForAccessibility="no"
app:srcCompat="@drawable/symbol_at_24"
app:contentPadding="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Signal.Circle"
app:tint="@color/signal_colorOnSurfaceVariant" />
<TextView
android:id="@+id/invite_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:ellipsize="marquee"
android:labelFor="@id/action_icon"
android:singleLine="true"
android:text="@string/ContactSelectionListFragment__find_by_username"
android:textAppearance="@style/Signal.Text.BodyLarge"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/invite_image"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -1,12 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:viewBindingIgnore="true"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="fill_parent"
android:layout_height="fill_parent"
android:layout_gravity="center"
android:orientation="vertical">
android:orientation="vertical"
tools:viewBindingIgnore="true">
<org.thoughtcrime.securesms.util.views.DarkOverflowToolbar
android:id="@+id/toolbar"
@@ -29,6 +29,7 @@
android:layout_marginTop="10dp"
android:layout_marginRight="16dp"
android:minHeight="44sp"
app:cfv_autoFocus="false"
app:layout_constraintTop_toBottomOf="@id/toolbar" />
<fragment

View File

@@ -252,7 +252,7 @@
<attr name="state_flash_on" format="boolean" />
</declare-styleable>
<declare-styleable name="ContactFilterToolbar">
<declare-styleable name="ContactFilterView">
<attr name="searchTextStyle" format="reference" />
<attr name="showDialpad" format="boolean" />
<attr name="cfv_autoFocus" format="boolean" />

View File

@@ -2570,6 +2570,10 @@
<item quantity="one">%1$d member</item>
<item quantity="other">%1$d members</item>
</plurals>
<!-- Text on row item to find user by phone number -->
<string name="ContactSelectionListFragment__find_by_phone_number">Find by phone number</string>
<!-- Text on row item to find user by username -->
<string name="ContactSelectionListFragment__find_by_username">Find by username</string>
<!-- contact_selection_list_fragment -->
<string name="contact_selection_list_fragment__signal_needs_access_to_your_contacts_in_order_to_display_them">Signal needs access to your contacts in order to display them.</string>
@@ -6452,5 +6456,41 @@
<!-- Bottom sheet dialog shown when a monthly donation fails to renew, second button to dismiss the dialog entirely -->
<string name="MonthlyDonationCanceled__not_now_button">Not now</string>
<!-- FindByActivity -->
<!-- Title of activity when finding by username -->
<string name="FindByActivity__find_by_username">Find by username</string>
<!-- Title of activity when finding by phone number -->
<string name="FindByActivity__find_by_phone_number">Find by phone number</string>
<!-- Title of screen to select a country code -->
<string name="FindByActivity__select_country_code">Select country code</string>
<!-- Entry placeholder for find by username -->
<string name="FindByActivity__username">Username</string>
<!-- Entry placeholder for find by phone number -->
<string name="FindByActivity__phone_number">Phone number</string>
<!-- Help text under user entry for find by username -->
<string name="FindByActivity__enter_a_full_username">Enter a full username with its pair of digits.</string>
<!-- Content description for next action button -->
<string name="FindByActivity__next">Next</string>
<!-- Placeholder text for search input for selecting country code -->
<string name="FindByActivity__search">Search</string>
<!-- Dialog title for invalid username -->
<string name="FindByActivity__invalid_username">Invalid username</string>
<!-- Dialog title for invalid phone number -->
<string name="FindByActivity__invalid_phone_number">Invalid phone number</string>
<!-- Dialog title when phone number is not a registered signal user -->
<string name="FindByActivity__invite_to_signal">Invite to Signal</string>
<!-- Dialog title when username is not found -->
<string name="FindByActivity__username_not_found">Username not found</string>
<!-- Dialog body for invalid username. Placeholder is the entered username. -->
<string name="FindByActivity__s_is_not_a_valid_username">%1$s is not a valid username. Make sure you\'ve entered the complete username followed by its set of digits.</string>
<!-- Dialog body for an invalid phone number. Placeholder is the entered phone number. -->
<string name="FindByActivity__s_is_not_a_valid_phone_number">%1$s is not valid phone number. Try again with a valid phone number</string>
<!-- Dialog body for not found username -->
<string name="FindByActivity__s_is_not_a_signal_user">%1$s is not a Signal user. Please check the username and try again.</string>
<!-- Dialog body for not found phone number -->
<string name="FindByActivity__s_is_not_a_signal_user_would">%1$s is not a Signal user. Would you like to invite this number?</string>
<!-- Dialog action to invite the phone number to Signal -->
<string name="FindByActivity__invite">Invite</string>
<!-- EOF -->
</resources>

View File

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

View File

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

View File

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

View File

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