mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-15 07:28:30 +00:00
Add Find By Username and Find By Phone Number interstitials.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
This commit is contained in:
committed by
Greyson Parrelli
parent
ca3d239ce2
commit
700fe5e463
@@ -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)
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
9
app/src/main/res/drawable/symbol_number_24.xml
Normal file
9
app/src/main/res/drawable/symbol_number_24.xml
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
124
core-ui/src/main/java/org/signal/core/ui/TextFields.kt
Normal file
124
core-ui/src/main/java/org/signal/core/ui/TextFields.kt
Normal 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
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user