Refactor ContactSelectionListFragment to use ContactSearch infrastructure.

This commit is contained in:
Alex Hart 2023-02-02 14:02:14 -04:00 committed by Nicholas Tinsley
parent 0f6bc0471c
commit 7fbfc09a89
39 changed files with 1088 additions and 1249 deletions

View file

@ -26,7 +26,7 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import org.signal.core.util.logging.Log; import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.components.ContactFilterView; import org.thoughtcrime.securesms.components.ContactFilterView;
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode; import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery; import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.RecipientId;
@ -71,7 +71,7 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
protected void onCreate(Bundle icicle, boolean ready) { protected void onCreate(Bundle icicle, boolean ready) {
if (!getIntent().hasExtra(ContactSelectionListFragment.DISPLAY_MODE)) { if (!getIntent().hasExtra(ContactSelectionListFragment.DISPLAY_MODE)) {
boolean includeSms = Util.isDefaultSmsProvider(this) && SignalStore.misc().getSmsExportPhase().allowSmsFeatures(); boolean includeSms = Util.isDefaultSmsProvider(this) && SignalStore.misc().getSmsExportPhase().allowSmsFeatures();
int displayMode = includeSms ? DisplayMode.FLAG_ALL : DisplayMode.FLAG_PUSH | DisplayMode.FLAG_ACTIVE_GROUPS | DisplayMode.FLAG_INACTIVE_GROUPS | DisplayMode.FLAG_SELF; int displayMode = includeSms ? ContactSelectionDisplayMode.FLAG_ALL : ContactSelectionDisplayMode.FLAG_PUSH | ContactSelectionDisplayMode.FLAG_ACTIVE_GROUPS | ContactSelectionDisplayMode.FLAG_INACTIVE_GROUPS | ContactSelectionDisplayMode.FLAG_SELF;
getIntent().putExtra(ContactSelectionListFragment.DISPLAY_MODE, displayMode); getIntent().putExtra(ContactSelectionListFragment.DISPLAY_MODE, displayMode);
} }

View file

@ -0,0 +1,68 @@
package org.thoughtcrime.securesms
import android.content.Context
import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
import org.thoughtcrime.securesms.contacts.paged.ContactSearchData
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
class ContactSelectionListAdapter(
context: Context,
displayCheckBox: Boolean,
displaySmsTag: DisplaySmsTag,
displayPhoneNumber: DisplayPhoneNumber,
onClickCallbacks: OnContactSelectionClick,
longClickCallbacks: LongClickCallbacks,
storyContextMenuCallbacks: StoryContextMenuCallbacks
) : ContactSearchAdapter(context, emptySet(), displayCheckBox, displaySmsTag, displayPhoneNumber, onClickCallbacks, longClickCallbacks, storyContextMenuCallbacks) {
init {
registerFactory(NewGroupModel::class.java, LayoutFactory({ StaticMappingViewHolder(it, onClickCallbacks::onNewGroupClicked) }, R.layout.contact_selection_new_group_item))
registerFactory(InviteToSignalModel::class.java, LayoutFactory({ StaticMappingViewHolder(it, onClickCallbacks::onInviteToSignalClicked) }, R.layout.contact_selection_invite_action_item))
}
class NewGroupModel : MappingModel<NewGroupModel> {
override fun areItemsTheSame(newItem: NewGroupModel): Boolean = true
override fun areContentsTheSame(newItem: NewGroupModel): Boolean = true
}
class InviteToSignalModel : MappingModel<InviteToSignalModel> {
override fun areItemsTheSame(newItem: InviteToSignalModel): Boolean = true
override fun areContentsTheSame(newItem: InviteToSignalModel): Boolean = true
}
class ArbitraryRepository : org.thoughtcrime.securesms.contacts.paged.ArbitraryRepository {
enum class ArbitraryRow(val code: String) {
NEW_GROUP("new-group"),
INVITE_TO_SIGNAL("invite-to-signal");
companion object {
fun fromCode(code: String) = values().first { it.code == code }
}
}
override fun getSize(section: ContactSearchConfiguration.Section.Arbitrary, query: String?): Int {
return if (query.isNullOrEmpty()) section.types.size else 0
}
override fun getData(section: ContactSearchConfiguration.Section.Arbitrary, query: String?, startIndex: Int, endIndex: Int, totalSearchSize: Int): List<ContactSearchData.Arbitrary> {
check(section.types.size == 1)
return listOf(ContactSearchData.Arbitrary(section.types.first()))
}
override fun getMappingModel(arbitrary: ContactSearchData.Arbitrary): MappingModel<*> {
val code = ArbitraryRow.fromCode(arbitrary.type)
return when (code) {
ArbitraryRow.NEW_GROUP -> NewGroupModel()
ArbitraryRow.INVITE_TO_SIGNAL -> InviteToSignalModel()
}
}
}
interface OnContactSelectionClick : ClickCallbacks {
fun onNewGroupClicked()
fun onInviteToSignalClicked()
}
}

View file

@ -21,7 +21,6 @@ import android.Manifest;
import android.annotation.SuppressLint; import android.annotation.SuppressLint;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.database.Cursor;
import android.graphics.Rect; import android.graphics.Rect;
import android.os.AsyncTask; import android.os.AsyncTask;
import android.os.Bundle; import android.os.Bundle;
@ -40,10 +39,7 @@ import androidx.annotation.Px;
import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AlertDialog;
import androidx.constraintlayout.widget.ConstraintLayout; import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.constraintlayout.widget.ConstraintSet; import androidx.constraintlayout.widget.ConstraintSet;
import androidx.fragment.app.FragmentActivity;
import androidx.lifecycle.ViewModelProvider; import androidx.lifecycle.ViewModelProvider;
import androidx.loader.app.LoaderManager;
import androidx.loader.content.Loader;
import androidx.recyclerview.widget.DefaultItemAnimator; import androidx.recyclerview.widget.DefaultItemAnimator;
import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView;
@ -59,32 +55,29 @@ import com.pnikosis.materialishprogress.ProgressWheel;
import org.signal.core.util.concurrent.SimpleTask; import org.signal.core.util.concurrent.SimpleTask;
import org.signal.core.util.logging.Log; import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.components.RecyclerViewFastScroller; import org.thoughtcrime.securesms.components.RecyclerViewFastScroller;
import org.thoughtcrime.securesms.contacts.AbstractContactsCursorLoader;
import org.thoughtcrime.securesms.contacts.ContactChipViewModel; import org.thoughtcrime.securesms.contacts.ContactChipViewModel;
import org.thoughtcrime.securesms.contacts.ContactSelectionListAdapter; import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
import org.thoughtcrime.securesms.contacts.ContactSelectionListItem;
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader;
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode;
import org.thoughtcrime.securesms.contacts.HeaderAction; import org.thoughtcrime.securesms.contacts.HeaderAction;
import org.thoughtcrime.securesms.contacts.LetterHeaderDecoration; import org.thoughtcrime.securesms.contacts.LetterHeaderDecoration;
import org.thoughtcrime.securesms.contacts.SelectedContact; import org.thoughtcrime.securesms.contacts.SelectedContact;
import org.thoughtcrime.securesms.contacts.SelectedContacts; import org.thoughtcrime.securesms.contacts.SelectedContacts;
import org.thoughtcrime.securesms.contacts.selection.ContactSelectionArguments; import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchData;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchMediator;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchSortOrder;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchState;
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery; import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
import org.thoughtcrime.securesms.groups.SelectionLimits; import org.thoughtcrime.securesms.groups.SelectionLimits;
import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog; import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.sharing.ShareContact;
import org.thoughtcrime.securesms.util.LifecycleDisposable; import org.thoughtcrime.securesms.util.LifecycleDisposable;
import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.UsernameUtil; import org.thoughtcrime.securesms.util.UsernameUtil;
import org.thoughtcrime.securesms.util.ViewUtil; import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.adapter.FixedViewsAdapter;
import org.thoughtcrime.securesms.util.adapter.RecyclerViewConcatenateAdapterStickyHeader;
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter; import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter;
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModelList; import org.thoughtcrime.securesms.util.adapter.mapping.MappingModelList;
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog; import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
@ -105,7 +98,6 @@ import kotlin.Unit;
* @author Moxie Marlinspike * @author Moxie Marlinspike
*/ */
public final class ContactSelectionListFragment extends LoggingFragment public final class ContactSelectionListFragment extends LoggingFragment
implements LoaderManager.LoaderCallbacks<Cursor>
{ {
@SuppressWarnings("unused") @SuppressWarnings("unused")
private static final String TAG = Log.tag(ContactSelectionListFragment.class); private static final String TAG = Log.tag(ContactSelectionListFragment.class);
@ -137,27 +129,23 @@ public final class ContactSelectionListFragment extends LoggingFragment
private String cursorFilter; private String cursorFilter;
private RecyclerView recyclerView; private RecyclerView recyclerView;
private RecyclerViewFastScroller fastScroller; private RecyclerViewFastScroller fastScroller;
private ContactSelectionListAdapter cursorRecyclerViewAdapter;
private RecyclerView chipRecycler; private RecyclerView chipRecycler;
private OnSelectionLimitReachedListener onSelectionLimitReachedListener; private OnSelectionLimitReachedListener onSelectionLimitReachedListener;
private AbstractContactsCursorLoaderFactoryProvider cursorFactoryProvider;
private MappingAdapter contactChipAdapter; private MappingAdapter contactChipAdapter;
private ContactChipViewModel contactChipViewModel; private ContactChipViewModel contactChipViewModel;
private LifecycleDisposable lifecycleDisposable; private LifecycleDisposable lifecycleDisposable;
private HeaderActionProvider headerActionProvider; private HeaderActionProvider headerActionProvider;
private TextView headerActionView; private TextView headerActionView;
private ContactSearchMediator contactSearchMediator;
@Nullable private FixedViewsAdapter headerAdapter;
@Nullable private FixedViewsAdapter footerAdapter;
@Nullable private ListCallback listCallback; @Nullable private ListCallback listCallback;
@Nullable private ScrollCallback scrollCallback; @Nullable private ScrollCallback scrollCallback;
@Nullable private OnItemLongClickListener onItemLongClickListener; @Nullable private OnItemLongClickListener onItemLongClickListener;
private GlideRequests glideRequests;
private SelectionLimits selectionLimit = SelectionLimits.NO_LIMITS; private SelectionLimits selectionLimit = SelectionLimits.NO_LIMITS;
private Set<RecipientId> currentSelection; private Set<RecipientId> currentSelection;
private boolean isMulti; private boolean isMulti;
private boolean hideCount;
private boolean canSelectSelf; private boolean canSelectSelf;
private ListClickListener listClickListener = new ListClickListener();
@Override @Override
public void onAttach(@NonNull Context context) { public void onAttach(@NonNull Context context) {
@ -191,14 +179,6 @@ public final class ContactSelectionListFragment extends LoggingFragment
onSelectionLimitReachedListener = (OnSelectionLimitReachedListener) getParentFragment(); onSelectionLimitReachedListener = (OnSelectionLimitReachedListener) getParentFragment();
} }
if (context instanceof AbstractContactsCursorLoaderFactoryProvider) {
cursorFactoryProvider = (AbstractContactsCursorLoaderFactoryProvider) context;
}
if (getParentFragment() instanceof AbstractContactsCursorLoaderFactoryProvider) {
cursorFactoryProvider = (AbstractContactsCursorLoaderFactoryProvider) getParentFragment();
}
if (context instanceof HeaderActionProvider) { if (context instanceof HeaderActionProvider) {
headerActionProvider = (HeaderActionProvider) context; headerActionProvider = (HeaderActionProvider) context;
} }
@ -234,16 +214,14 @@ public final class ContactSelectionListFragment extends LoggingFragment
if (!TextSecurePreferences.hasSuccessfullyRetrievedDirectory(getActivity())) { if (!TextSecurePreferences.hasSuccessfullyRetrievedDirectory(getActivity())) {
handleContactPermissionGranted(); handleContactPermissionGranted();
} else { } else {
LoaderManager.getInstance(this).initLoader(0, null, this); contactSearchMediator.refresh();
} }
}) })
.onAnyDenied(() -> { .onAnyDenied(() -> {
FragmentActivity activity = requireActivity(); requireActivity().getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN);
activity.getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN); if (safeArguments().getBoolean(RECENTS, requireActivity().getIntent().getBooleanExtra(RECENTS, false))) {
contactSearchMediator.refresh();
if (safeArguments().getBoolean(RECENTS, activity.getIntent().getBooleanExtra(RECENTS, false))) {
LoaderManager.getInstance(this).initLoader(0, null, ContactSelectionListFragment.this);
} else { } else {
initializeNoContactsPermission(); initializeNoContactsPermission();
} }
@ -305,7 +283,6 @@ public final class ContactSelectionListFragment extends LoggingFragment
swipeRefresh.setNestedScrollingEnabled(isRefreshable); swipeRefresh.setNestedScrollingEnabled(isRefreshable);
swipeRefresh.setEnabled(isRefreshable); swipeRefresh.setEnabled(isRefreshable);
hideCount = arguments.getBoolean(HIDE_COUNT, intent.getBooleanExtra(HIDE_COUNT, false));
selectionLimit = arguments.getParcelable(SELECTION_LIMITS); selectionLimit = arguments.getParcelable(SELECTION_LIMITS);
if (selectionLimit == null) { if (selectionLimit == null) {
selectionLimit = intent.getParcelableExtra(SELECTION_LIMITS); selectionLimit = intent.getParcelableExtra(SELECTION_LIMITS);
@ -353,6 +330,65 @@ public final class ContactSelectionListFragment extends LoggingFragment
headerActionView.setEnabled(false); headerActionView.setEnabled(false);
} }
contactSearchMediator = new ContactSearchMediator(
this,
currentSelection.stream()
.map(r -> new ContactSearchKey.RecipientSearchKey(r, false))
.collect(java.util.stream.Collectors.toSet()),
selectionLimit,
isMulti,
ContactSearchAdapter.DisplaySmsTag.DEFAULT,
ContactSearchAdapter.DisplayPhoneNumber.ALWAYS,
this::mapStateToConfiguration,
new ContactSearchMediator.SimpleCallbacks() {
@Override
public void onAdapterListCommitted(int size) {
onLoadFinished(size);
}
},
false,
(context, fixedContacts, displayCheckBox, displaySmsTag, displayPhoneNumber, callbacks, longClickCallbacks, storyContextMenuCallbacks) -> new ContactSelectionListAdapter(
context,
displayCheckBox,
displaySmsTag,
displayPhoneNumber,
new ContactSelectionListAdapter.OnContactSelectionClick() {
@Override
public void onNewGroupClicked() {
listCallback.onNewGroup(false);
}
@Override
public void onInviteToSignalClicked() {
listCallback.onInvite();
}
@Override
public void onStoryClicked(@NonNull View view1, @NonNull ContactSearchData.Story story, boolean isSelected) {
throw new UnsupportedOperationException();
}
@Override
public void onKnownRecipientClicked(@NonNull View view1, @NonNull ContactSearchData.KnownRecipient knownRecipient, boolean isSelected) {
listClickListener.onItemClick(knownRecipient.getContactSearchKey());
}
@Override
public void onExpandClicked(@NonNull ContactSearchData.Expand expand) {
callbacks.onExpandClicked(expand);
}
@Override
public void onUnknownRecipientClicked(@NonNull View view, @NonNull ContactSearchData.UnknownRecipient unknownRecipient, boolean isSelected) {
listClickListener.onItemClick(unknownRecipient.getContactSearchKey());
}
},
(anchorView, data) -> listClickListener.onItemLongClick(anchorView, data.getContactSearchKey()),
storyContextMenuCallbacks
),
new ContactSelectionListAdapter.ArbitraryRepository()
);
return view; return view;
} }
@ -372,27 +408,30 @@ public final class ContactSelectionListFragment extends LoggingFragment
} }
public @NonNull List<SelectedContact> getSelectedContacts() { public @NonNull List<SelectedContact> getSelectedContacts() {
if (cursorRecyclerViewAdapter == null) { if (contactSearchMediator == null) {
return Collections.emptyList(); return Collections.emptyList();
} }
return cursorRecyclerViewAdapter.getSelectedContacts(); return contactSearchMediator.getSelectedContacts()
.stream()
.map(ContactSearchKey::requireSelectedContact)
.collect(java.util.stream.Collectors.toList());
} }
public int getSelectedContactsCount() { public int getSelectedContactsCount() {
if (cursorRecyclerViewAdapter == null) { if (contactSearchMediator == null) {
return 0; return 0;
} }
return cursorRecyclerViewAdapter.getSelectedContactsCount(); return contactSearchMediator.getSelectedContacts().size();
} }
public int getTotalMemberCount() { public int getTotalMemberCount() {
if (cursorRecyclerViewAdapter == null) { if (contactSearchMediator == null) {
return 0; return 0;
} }
return cursorRecyclerViewAdapter.getSelectedContactsCount() + cursorRecyclerViewAdapter.getCurrentContactsCount(); return getSelectedContactsCount() + contactSearchMediator.getFixedContactsSize();
} }
private Set<RecipientId> getCurrentSelection() { private Set<RecipientId> getCurrentSelection() {
@ -410,34 +449,8 @@ public final class ContactSelectionListFragment extends LoggingFragment
} }
private void initializeCursor() { private void initializeCursor() {
glideRequests = GlideApp.with(this);
cursorRecyclerViewAdapter = new ContactSelectionListAdapter(requireContext(),
glideRequests,
null,
new ListClickListener(),
isMulti,
currentSelection,
safeArguments().getInt(ContactSelectionArguments.CHECKBOX_RESOURCE, R.drawable.contact_selection_checkbox));
RecyclerViewConcatenateAdapterStickyHeader concatenateAdapter = new RecyclerViewConcatenateAdapterStickyHeader();
if (listCallback != null) {
headerAdapter = new FixedViewsAdapter(createNewGroupItem(listCallback));
headerAdapter.hide();
concatenateAdapter.addAdapter(headerAdapter);
}
concatenateAdapter.addAdapter(cursorRecyclerViewAdapter);
if (listCallback != null) {
footerAdapter = new FixedViewsAdapter(createInviteActionView(listCallback));
footerAdapter.hide();
concatenateAdapter.addAdapter(footerAdapter);
}
recyclerView.addItemDecoration(new LetterHeaderDecoration(requireContext(), this::hideLetterHeaders)); recyclerView.addItemDecoration(new LetterHeaderDecoration(requireContext(), this::hideLetterHeaders));
recyclerView.setAdapter(concatenateAdapter); recyclerView.setAdapter(contactSearchMediator.getAdapter());
recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() { recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override @Override
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) { public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
@ -458,20 +471,6 @@ public final class ContactSelectionListFragment extends LoggingFragment
return hasQueryFilter() || shouldDisplayRecents(); return hasQueryFilter() || shouldDisplayRecents();
} }
private View createInviteActionView(@NonNull ListCallback listCallback) {
View view = LayoutInflater.from(requireContext())
.inflate(R.layout.contact_selection_invite_action_item, (ViewGroup) requireView(), false);
view.setOnClickListener(v -> listCallback.onInvite());
return view;
}
private View createNewGroupItem(@NonNull ListCallback listCallback) {
View view = LayoutInflater.from(requireContext())
.inflate(R.layout.contact_selection_new_group_item, (ViewGroup) requireView(), false);
view.setOnClickListener(v -> listCallback.onNewGroup(false));
return view;
}
private void initializeNoContactsPermission() { private void initializeNoContactsPermission() {
swipeRefresh.setVisibility(View.GONE); swipeRefresh.setVisibility(View.GONE);
@ -496,7 +495,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
public void setQueryFilter(String filter) { public void setQueryFilter(String filter) {
this.cursorFilter = filter; this.cursorFilter = filter;
LoaderManager.getInstance(this).restartLoader(0, null, this); contactSearchMediator.onFilterChanged(filter);
} }
public void resetQueryFilter() { public void resetQueryFilter() {
@ -513,51 +512,21 @@ public final class ContactSelectionListFragment extends LoggingFragment
} }
public void reset() { public void reset() {
cursorRecyclerViewAdapter.clearSelectedContacts(); contactSearchMediator.clearSelection();
fastScroller.setVisibility(View.GONE);
if (!isDetached() && !isRemoving() && getActivity() != null && !getActivity().isFinishing()) { headerActionView.setVisibility(View.GONE);
LoaderManager.getInstance(this).restartLoader(0, null, this);
}
} }
public void setRecyclerViewPaddingBottom(@Px int paddingBottom) { public void setRecyclerViewPaddingBottom(@Px int paddingBottom) {
ViewUtil.setPaddingBottom(recyclerView, paddingBottom); ViewUtil.setPaddingBottom(recyclerView, paddingBottom);
} }
@Override private void onLoadFinished(int count) {
public @NonNull Loader<Cursor> onCreateLoader(int id, Bundle args) {
FragmentActivity activity = requireActivity();
int displayMode = safeArguments().getInt(DISPLAY_MODE, activity.getIntent().getIntExtra(DISPLAY_MODE, DisplayMode.FLAG_ALL));
boolean displayRecents = shouldDisplayRecents();
if (cursorFactoryProvider != null) {
return cursorFactoryProvider.get().create();
} else {
return new ContactsCursorLoader.Factory(activity, displayMode, cursorFilter, displayRecents).create();
}
}
@Override
public void onLoadFinished(@NonNull Loader<Cursor> loader, @Nullable Cursor data) {
swipeRefresh.setVisibility(View.VISIBLE); swipeRefresh.setVisibility(View.VISIBLE);
showContactsLayout.setVisibility(View.GONE); showContactsLayout.setVisibility(View.GONE);
cursorRecyclerViewAdapter.changeCursor(data);
if (footerAdapter != null) {
footerAdapter.show();
}
if (headerAdapter != null) {
if (TextUtils.isEmpty(cursorFilter)) {
headerAdapter.show();
} else {
headerAdapter.hide();
}
}
emptyText.setText(R.string.contact_selection_group_activity__no_contacts); emptyText.setText(R.string.contact_selection_group_activity__no_contacts);
boolean useFastScroller = data != null && data.getCount() > 20; boolean useFastScroller = count > 20;
recyclerView.setVerticalScrollBarEnabled(!useFastScroller); recyclerView.setVerticalScrollBarEnabled(!useFastScroller);
if (useFastScroller) { if (useFastScroller) {
fastScroller.setVisibility(View.VISIBLE); fastScroller.setVisibility(View.VISIBLE);
@ -574,13 +543,6 @@ public final class ContactSelectionListFragment extends LoggingFragment
} }
} }
@Override
public void onLoaderReset(@NonNull Loader<Cursor> loader) {
cursorRecyclerViewAdapter.changeCursor(null);
fastScroller.setVisibility(View.GONE);
headerActionView.setVisibility(View.GONE);
}
private boolean shouldDisplayRecents() { private boolean shouldDisplayRecents() {
return safeArguments().getBoolean(RECENTS, requireActivity().getIntent().getBooleanExtra(RECENTS, false)); return safeArguments().getBoolean(RECENTS, requireActivity().getIntent().getBooleanExtra(RECENTS, false));
} }
@ -634,20 +596,15 @@ public final class ContactSelectionListFragment extends LoggingFragment
* *
* @param contacts List of the contacts to select. This will not overwrite the current selection, but append to it. * @param contacts List of the contacts to select. This will not overwrite the current selection, but append to it.
*/ */
public void markSelected(@NonNull Set<ShareContact> contacts) { public void markSelected(@NonNull Set<RecipientId> contacts) {
if (contacts.isEmpty()) { if (contacts.isEmpty()) {
return; return;
} }
Set<SelectedContact> toMarkSelected = contacts.stream() Set<SelectedContact> toMarkSelected = contacts.stream()
.map(contact -> { .filter(r -> !contactSearchMediator.getSelectedContacts()
if (contact.getRecipientId().isPresent()) { .contains(new ContactSearchKey.RecipientSearchKey(r, false)))
return SelectedContact.forRecipientId(contact.getRecipientId().get()); .map(SelectedContact::forRecipientId)
} else {
return SelectedContact.forPhone(null, contact.getNumber());
}
})
.filter(c -> !cursorRecyclerViewAdapter.isSelectedContact(c))
.collect(java.util.stream.Collectors.toSet()); .collect(java.util.stream.Collectors.toSet());
if (toMarkSelected.isEmpty()) { if (toMarkSelected.isEmpty()) {
@ -657,22 +614,18 @@ public final class ContactSelectionListFragment extends LoggingFragment
for (final SelectedContact selectedContact : toMarkSelected) { for (final SelectedContact selectedContact : toMarkSelected) {
markContactSelected(selectedContact); markContactSelected(selectedContact);
} }
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount());
} }
private class ListClickListener implements ContactSelectionListAdapter.ItemClickListener { private class ListClickListener {
@Override public void onItemClick(ContactSearchKey contact) {
public void onItemClick(ContactSelectionListItem contact) { SelectedContact selectedContact = contact.requireSelectedContact();
SelectedContact selectedContact = contact.isUsernameType() ? SelectedContact.forUsername(contact.getRecipientId().orElse(null), contact.getNumber())
: SelectedContact.forPhone(contact.getRecipientId().orElse(null), contact.getNumber());
if (!canSelectSelf && !selectedContact.hasUsername() && Recipient.self().getId().equals(selectedContact.getOrCreateRecipientId(requireContext()))) { if (!canSelectSelf && !selectedContact.hasUsername() && Recipient.self().getId().equals(selectedContact.getOrCreateRecipientId(requireContext()))) {
Toast.makeText(requireContext(), R.string.ContactSelectionListFragment_you_do_not_need_to_add_yourself_to_the_group, Toast.LENGTH_SHORT).show(); Toast.makeText(requireContext(), R.string.ContactSelectionListFragment_you_do_not_need_to_add_yourself_to_the_group, Toast.LENGTH_SHORT).show();
return; return;
} }
if (!isMulti || !cursorRecyclerViewAdapter.isSelectedContact(selectedContact)) { if (!isMulti || !contactSearchMediator.getSelectedContacts().contains(selectedContact.toContactSearchKey())) {
if (selectionHardLimitReached()) { if (selectionHardLimitReached()) {
if (onSelectionLimitReachedListener != null) { if (onSelectionLimitReachedListener != null) {
onSelectionLimitReachedListener.onHardLimitReached(selectionLimit.getHardLimit()); onSelectionLimitReachedListener.onHardLimitReached(selectionLimit.getHardLimit());
@ -682,63 +635,58 @@ public final class ContactSelectionListFragment extends LoggingFragment
return; return;
} }
if (contact.isUsernameType()) { if (contact instanceof ContactSearchKey.UnknownRecipientKey && ((ContactSearchKey.UnknownRecipientKey) contact).getSectionKey() == ContactSearchConfiguration.SectionKey.USERNAME) {
String username = ((ContactSearchKey.UnknownRecipientKey) contact).getQuery();
AlertDialog loadingDialog = SimpleProgressDialog.show(requireContext()); AlertDialog loadingDialog = SimpleProgressDialog.show(requireContext());
SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> { SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> {
return UsernameUtil.fetchAciForUsername(contact.getNumber()); return UsernameUtil.fetchAciForUsername(username);
}, uuid -> { }, uuid -> {
loadingDialog.dismiss(); loadingDialog.dismiss();
if (uuid.isPresent()) { if (uuid.isPresent()) {
Recipient recipient = Recipient.externalUsername(uuid.get(), contact.getNumber()); Recipient recipient = Recipient.externalUsername(uuid.get(), username);
SelectedContact selected = SelectedContact.forUsername(recipient.getId(), contact.getNumber()); SelectedContact selected = SelectedContact.forUsername(recipient.getId(), username);
if (onContactSelectedListener != null) { if (onContactSelectedListener != null) {
onContactSelectedListener.onBeforeContactSelected(Optional.of(recipient.getId()), null, allowed -> { onContactSelectedListener.onBeforeContactSelected(Optional.of(recipient.getId()), null, allowed -> {
if (allowed) { if (allowed) {
markContactSelected(selected); markContactSelected(selected);
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
} }
}); });
} else { } else {
markContactSelected(selected); markContactSelected(selected);
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
} }
} else { } else {
new MaterialAlertDialogBuilder(requireContext()) new MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.ContactSelectionListFragment_username_not_found) .setTitle(R.string.ContactSelectionListFragment_username_not_found)
.setMessage(getString(R.string.ContactSelectionListFragment_s_is_not_a_signal_user, contact.getNumber())) .setMessage(getString(R.string.ContactSelectionListFragment_s_is_not_a_signal_user, username))
.setPositiveButton(android.R.string.ok, (dialog, which) -> dialog.dismiss()) .setPositiveButton(android.R.string.ok, (dialog, which) -> dialog.dismiss())
.show(); .show();
} }
}); });
} else { } else {
if (onContactSelectedListener != null) { if (onContactSelectedListener != null) {
onContactSelectedListener.onBeforeContactSelected(contact.getRecipientId(), contact.getNumber(), allowed -> { onContactSelectedListener.onBeforeContactSelected(Optional.ofNullable(selectedContact.getRecipientId()), selectedContact.getNumber(), allowed -> {
if (allowed) { if (allowed) {
markContactSelected(selectedContact); markContactSelected(selectedContact);
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
} }
}); });
} else { } else {
markContactSelected(selectedContact); markContactSelected(selectedContact);
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
} }
} }
} else { } else {
markContactUnselected(selectedContact); markContactUnselected(selectedContact);
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
if (onContactSelectedListener != null) { if (onContactSelectedListener != null) {
onContactSelectedListener.onContactDeselected(contact.getRecipientId(), contact.getNumber()); onContactSelectedListener.onContactDeselected(Optional.ofNullable(selectedContact.getRecipientId()), selectedContact.getNumber());
} }
} }
} }
@Override public boolean onItemLongClick(View anchorView, ContactSearchKey item) {
public boolean onItemLongClick(ContactSelectionListItem item) {
if (onItemLongClickListener != null) { if (onItemLongClickListener != null) {
return onItemLongClickListener.onLongClick(item, recyclerView); return onItemLongClickListener.onLongClick(anchorView, item, recyclerView);
} else { } else {
return false; return false;
} }
@ -758,7 +706,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
} }
private void markContactSelected(@NonNull SelectedContact selectedContact) { private void markContactSelected(@NonNull SelectedContact selectedContact) {
cursorRecyclerViewAdapter.addSelectedContact(selectedContact); contactSearchMediator.setKeysSelected(Collections.singleton(selectedContact.toContactSearchKey()));
if (isMulti) { if (isMulti) {
addChipForSelectedContact(selectedContact); addChipForSelectedContact(selectedContact);
} }
@ -768,8 +716,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
} }
private void markContactUnselected(@NonNull SelectedContact selectedContact) { private void markContactUnselected(@NonNull SelectedContact selectedContact) {
cursorRecyclerViewAdapter.removeFromSelectedContacts(selectedContact); contactSearchMediator.setKeysNotSelected(Collections.singleton(selectedContact.toContactSearchKey()));
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
contactChipViewModel.remove(selectedContact); contactChipViewModel.remove(selectedContact);
if (onContactSelectedListener != null) { if (onContactSelectedListener != null) {
@ -842,6 +789,116 @@ public final class ContactSelectionListFragment extends LoggingFragment
chipRecycler.smoothScrollBy(x, 0); chipRecycler.smoothScrollBy(x, 0);
} }
private @NonNull ContactSearchConfiguration mapStateToConfiguration(@NonNull ContactSearchState contactSearchState) {
int displayMode = safeArguments().getInt(DISPLAY_MODE, requireActivity().getIntent().getIntExtra(DISPLAY_MODE, ContactSelectionDisplayMode.FLAG_ALL));
boolean includeRecents = safeArguments().getBoolean(RECENTS, requireActivity().getIntent().getBooleanExtra(RECENTS, false));
boolean includePushContacts = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_PUSH);
boolean includeSmsContacts = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_SMS);
boolean includeActiveGroups = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_ACTIVE_GROUPS);
boolean includeInactiveGroups = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_INACTIVE_GROUPS);
boolean includeSelf = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_SELF);
boolean includeV1Groups = !flagSet(displayMode, ContactSelectionDisplayMode.FLAG_HIDE_GROUPS_V1);
boolean includeNew = !flagSet(displayMode, ContactSelectionDisplayMode.FLAG_HIDE_NEW);
boolean includeRecentsHeader = !flagSet(displayMode, ContactSelectionDisplayMode.FLAG_HIDE_RECENT_HEADER);
boolean includeGroupsAfterContacts = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_GROUPS_AFTER_CONTACTS);
boolean blocked = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_BLOCK);
ContactSearchConfiguration.TransportType transportType = resolveTransportType(includePushContacts, includeSmsContacts);
ContactSearchConfiguration.Section.Recents.Mode mode = resolveRecentsMode(transportType, includeActiveGroups);
ContactSearchConfiguration.NewRowMode newRowMode = resolveNewRowMode(blocked, includeActiveGroups);
return ContactSearchConfiguration.build(builder -> {
builder.setQuery(contactSearchState.getQuery());
if (listCallback != null) {
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.NEW_GROUP.getCode());
}
if (transportType != null) {
if (TextUtils.isEmpty(contactSearchState.getQuery()) && includeRecents) {
builder.addSection(new ContactSearchConfiguration.Section.Recents(
25,
mode,
includeInactiveGroups,
includeV1Groups,
includeSmsContacts,
includeSelf,
includeRecentsHeader,
null
));
}
builder.addSection(new ContactSearchConfiguration.Section.Individuals(
includeSelf,
transportType,
true,
null,
!hideLetterHeaders()
));
}
if ((includeGroupsAfterContacts || !TextUtils.isEmpty(contactSearchState.getQuery())) && includeActiveGroups) {
builder.addSection(new ContactSearchConfiguration.Section.Groups(
includeSmsContacts,
includeV1Groups,
includeInactiveGroups,
false,
ContactSearchSortOrder.NATURAL,
false,
true,
null
));
}
if (listCallback != null) {
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.INVITE_TO_SIGNAL.getCode());
}
if (includeNew) {
builder.phone(newRowMode);
builder.username(newRowMode);
}
return Unit.INSTANCE;
});
}
private static @Nullable ContactSearchConfiguration.TransportType resolveTransportType(boolean includePushContacts, boolean includeSmsContacts) {
if (includePushContacts && includeSmsContacts) {
return ContactSearchConfiguration.TransportType.ALL;
} else if (includePushContacts) {
return ContactSearchConfiguration.TransportType.PUSH;
} else if (includeSmsContacts) {
return ContactSearchConfiguration.TransportType.SMS;
} else {
return null;
}
}
private static @NonNull ContactSearchConfiguration.Section.Recents.Mode resolveRecentsMode(ContactSearchConfiguration.TransportType transportType, boolean includeGroupContacts) {
if (transportType != null && includeGroupContacts) {
return ContactSearchConfiguration.Section.Recents.Mode.ALL;
} else if (includeGroupContacts) {
return ContactSearchConfiguration.Section.Recents.Mode.GROUPS;
} else {
return ContactSearchConfiguration.Section.Recents.Mode.INDIVIDUALS;
}
}
private static @NonNull ContactSearchConfiguration.NewRowMode resolveNewRowMode(boolean isBlocked, boolean isActiveGroups) {
if (isBlocked) {
return ContactSearchConfiguration.NewRowMode.BLOCK;
} else if (isActiveGroups) {
return ContactSearchConfiguration.NewRowMode.NEW_CONVERSATION;
} else {
return ContactSearchConfiguration.NewRowMode.ADD_TO_GROUP;
}
}
private static boolean flagSet(int mode, int flag) {
return (mode & flag) > 0;
}
public interface OnContactSelectedListener { public interface OnContactSelectedListener {
/** /**
* Provides an opportunity to disallow selecting an item. Call the callback with false to disallow, or true to allow it. * Provides an opportunity to disallow selecting an item. Call the callback with false to disallow, or true to allow it.
@ -874,10 +931,6 @@ public final class ContactSelectionListFragment extends LoggingFragment
} }
public interface OnItemLongClickListener { public interface OnItemLongClickListener {
boolean onLongClick(ContactSelectionListItem contactSelectionListItem, RecyclerView recyclerView); boolean onLongClick(View anchorView, ContactSearchKey contactSearchKey, RecyclerView recyclerView);
}
public interface AbstractContactsCursorLoaderFactoryProvider {
@NonNull AbstractContactsCursorLoader.Factory get();
} }
} }

View file

@ -23,7 +23,7 @@ import androidx.interpolator.view.animation.FastOutSlowInInterpolator;
import org.thoughtcrime.securesms.components.ContactFilterView; import org.thoughtcrime.securesms.components.ContactFilterView;
import org.thoughtcrime.securesms.components.ContactFilterView.OnFilterChangedListener; import org.thoughtcrime.securesms.components.ContactFilterView.OnFilterChangedListener;
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode; import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
import org.thoughtcrime.securesms.contacts.SelectedContact; import org.thoughtcrime.securesms.contacts.SelectedContact;
import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.groups.SelectionLimits; import org.thoughtcrime.securesms.groups.SelectionLimits;
@ -62,7 +62,7 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
@Override @Override
protected void onCreate(Bundle savedInstanceState, boolean ready) { protected void onCreate(Bundle savedInstanceState, boolean ready) {
getIntent().putExtra(ContactSelectionListFragment.DISPLAY_MODE, DisplayMode.FLAG_SMS); getIntent().putExtra(ContactSelectionListFragment.DISPLAY_MODE, ContactSelectionDisplayMode.FLAG_SMS);
getIntent().putExtra(ContactSelectionListFragment.SELECTION_LIMITS, SelectionLimits.NO_LIMITS); getIntent().putExtra(ContactSelectionListFragment.SELECTION_LIMITS, SelectionLimits.NO_LIMITS);
getIntent().putExtra(ContactSelectionListFragment.HIDE_COUNT, true); getIntent().putExtra(ContactSelectionListFragment.HIDE_COUNT, true);
getIntent().putExtra(ContactSelectionListFragment.REFRESHABLE, false); getIntent().putExtra(ContactSelectionListFragment.REFRESHABLE, false);

View file

@ -20,6 +20,7 @@ import android.content.Intent;
import android.os.Bundle; import android.os.Bundle;
import android.view.Menu; import android.view.Menu;
import android.view.MenuItem; import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.ActivityResultLauncher;
@ -42,6 +43,8 @@ import org.thoughtcrime.securesms.components.menu.SignalContextMenu;
import org.thoughtcrime.securesms.contacts.ContactSelectionListItem; import org.thoughtcrime.securesms.contacts.ContactSelectionListItem;
import org.thoughtcrime.securesms.contacts.management.ContactsManagementRepository; import org.thoughtcrime.securesms.contacts.management.ContactsManagementRepository;
import org.thoughtcrime.securesms.contacts.management.ContactsManagementViewModel; import org.thoughtcrime.securesms.contacts.management.ContactsManagementViewModel;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchData;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey;
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery; import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
import org.thoughtcrime.securesms.conversation.ConversationIntents; import org.thoughtcrime.securesms.conversation.ConversationIntents;
import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.SignalDatabase;
@ -233,18 +236,14 @@ public class NewConversationActivity extends ContactSelectionActivity
} }
@Override @Override
public boolean onLongClick(ContactSelectionListItem contactSelectionListItem, RecyclerView recyclerView) { public boolean onLongClick(View anchorView, ContactSearchKey contactSearchKey, RecyclerView recyclerView) {
RecipientId recipientId = contactSelectionListItem.getRecipientId().orElse(null); RecipientId recipientId = contactSearchKey.requireRecipientSearchKey().getRecipientId();
if (recipientId == null) {
return false;
}
List<ActionItem> actions = generateContextualActionsForRecipient(recipientId); List<ActionItem> actions = generateContextualActionsForRecipient(recipientId);
if (actions.isEmpty()) { if (actions.isEmpty()) {
return false; return false;
} }
new SignalContextMenu.Builder(contactSelectionListItem, (ViewGroup) contactSelectionListItem.getRootView()) new SignalContextMenu.Builder(anchorView, (ViewGroup) anchorView.getRootView())
.preferredVerticalPosition(SignalContextMenu.VerticalPosition.BELOW) .preferredVerticalPosition(SignalContextMenu.VerticalPosition.BELOW)
.preferredHorizontalPosition(SignalContextMenu.HorizontalPosition.START) .preferredHorizontalPosition(SignalContextMenu.HorizontalPosition.START)
.offsetX((int) DimensionUnit.DP.toPixels(12)) .offsetX((int) DimensionUnit.DP.toPixels(12))

View file

@ -0,0 +1,13 @@
package org.thoughtcrime.securesms
import android.view.View
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
class StaticMappingViewHolder<T : MappingModel<T>>(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<T>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: T) = Unit
}

View file

@ -19,7 +19,7 @@ import org.thoughtcrime.securesms.ContactSelectionListFragment;
import org.thoughtcrime.securesms.PassphraseRequiredActivity; import org.thoughtcrime.securesms.PassphraseRequiredActivity;
import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.ContactFilterView; import org.thoughtcrime.securesms.components.ContactFilterView;
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader; import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
@ -143,11 +143,11 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
intent.putExtra(ContactSelectionListFragment.SELECTION_LIMITS, 1); intent.putExtra(ContactSelectionListFragment.SELECTION_LIMITS, 1);
intent.putExtra(ContactSelectionListFragment.HIDE_COUNT, true); intent.putExtra(ContactSelectionListFragment.HIDE_COUNT, true);
intent.putExtra(ContactSelectionListFragment.DISPLAY_MODE, intent.putExtra(ContactSelectionListFragment.DISPLAY_MODE,
ContactsCursorLoader.DisplayMode.FLAG_PUSH | ContactSelectionDisplayMode.FLAG_PUSH |
ContactsCursorLoader.DisplayMode.FLAG_SMS | ContactSelectionDisplayMode.FLAG_SMS |
ContactsCursorLoader.DisplayMode.FLAG_ACTIVE_GROUPS | ContactSelectionDisplayMode.FLAG_ACTIVE_GROUPS |
ContactsCursorLoader.DisplayMode.FLAG_INACTIVE_GROUPS | ContactSelectionDisplayMode.FLAG_INACTIVE_GROUPS |
ContactsCursorLoader.DisplayMode.FLAG_BLOCK); ContactSelectionDisplayMode.FLAG_BLOCK);
getSupportFragmentManager().beginTransaction() getSupportFragmentManager().beginTransaction()
.replace(R.id.fragment_container, fragment, CONTACT_SELECTION_FRAGMENT) .replace(R.id.fragment_container, fragment, CONTACT_SELECTION_FRAGMENT)

View file

@ -13,7 +13,7 @@ import org.thoughtcrime.securesms.ContactSelectionListFragment
import org.thoughtcrime.securesms.LoggingFragment import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ContactFilterView import org.thoughtcrime.securesms.components.ContactFilterView
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode
import org.thoughtcrime.securesms.groups.SelectionLimits import org.thoughtcrime.securesms.groups.SelectionLimits
import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.recipients.RecipientId
@ -100,17 +100,17 @@ class SelectRecipientsFragment : LoggingFragment(), ContactSelectionListFragment
} }
private fun getDefaultDisplayMode(): Int { private fun getDefaultDisplayMode(): Int {
var mode = ContactsCursorLoader.DisplayMode.FLAG_PUSH or var mode = ContactSelectionDisplayMode.FLAG_PUSH or
ContactsCursorLoader.DisplayMode.FLAG_ACTIVE_GROUPS or ContactSelectionDisplayMode.FLAG_ACTIVE_GROUPS or
ContactsCursorLoader.DisplayMode.FLAG_HIDE_NEW or ContactSelectionDisplayMode.FLAG_HIDE_NEW or
ContactsCursorLoader.DisplayMode.FLAG_HIDE_RECENT_HEADER or ContactSelectionDisplayMode.FLAG_HIDE_RECENT_HEADER or
ContactsCursorLoader.DisplayMode.FLAG_GROUPS_AFTER_CONTACTS ContactSelectionDisplayMode.FLAG_GROUPS_AFTER_CONTACTS
if (Util.isDefaultSmsProvider(requireContext()) && SignalStore.misc().smsExportPhase.allowSmsFeatures()) { if (Util.isDefaultSmsProvider(requireContext()) && SignalStore.misc().smsExportPhase.allowSmsFeatures()) {
mode = mode or ContactsCursorLoader.DisplayMode.FLAG_SMS mode = mode or ContactSelectionDisplayMode.FLAG_SMS
} }
return mode or ContactsCursorLoader.DisplayMode.FLAG_HIDE_GROUPS_V1 return mode or ContactSelectionDisplayMode.FLAG_HIDE_GROUPS_V1
} }
override fun onBeforeContactSelected(recipientId: Optional<RecipientId>, number: String?, callback: Consumer<Boolean>) { override fun onBeforeContactSelected(recipientId: Optional<RecipientId>, number: String?, callback: Consumer<Boolean>) {

View file

@ -54,7 +54,7 @@ import org.thoughtcrime.securesms.components.settings.conversation.preferences.L
import org.thoughtcrime.securesms.components.settings.conversation.preferences.RecipientPreference import org.thoughtcrime.securesms.components.settings.conversation.preferences.RecipientPreference
import org.thoughtcrime.securesms.components.settings.conversation.preferences.SharedMediaPreference import org.thoughtcrime.securesms.components.settings.conversation.preferences.SharedMediaPreference
import org.thoughtcrime.securesms.components.settings.conversation.preferences.Utils.formatMutedUntil import org.thoughtcrime.securesms.components.settings.conversation.preferences.Utils.formatMutedUntil
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode
import org.thoughtcrime.securesms.conversation.ConversationIntents import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.groups.ParcelableGroupId import org.thoughtcrime.securesms.groups.ParcelableGroupId
import org.thoughtcrime.securesms.groups.ui.GroupErrors import org.thoughtcrime.securesms.groups.ui.GroupErrors
@ -768,7 +768,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
AddMembersActivity.createIntent( AddMembersActivity.createIntent(
requireContext(), requireContext(),
addMembersToGroup.groupId, addMembersToGroup.groupId,
ContactsCursorLoader.DisplayMode.FLAG_PUSH, ContactSelectionDisplayMode.FLAG_PUSH,
addMembersToGroup.selectionWarning, addMembersToGroup.selectionWarning,
addMembersToGroup.selectionLimit, addMembersToGroup.selectionLimit,
addMembersToGroup.isAnnouncementGroup, addMembersToGroup.isAnnouncementGroup,

View file

@ -37,7 +37,7 @@ public class ContactRepository {
private final Context context; private final Context context;
public static final String ID_COLUMN = "id"; public static final String ID_COLUMN = "id";
static final String NAME_COLUMN = "name"; public static final String NAME_COLUMN = "name";
static final String NUMBER_COLUMN = "number"; static final String NUMBER_COLUMN = "number";
static final String NUMBER_TYPE_COLUMN = "number_type"; static final String NUMBER_TYPE_COLUMN = "number_type";
static final String LABEL_COLUMN = "label"; static final String LABEL_COLUMN = "label";

View file

@ -0,0 +1,15 @@
package org.thoughtcrime.securesms.contacts;
public final class ContactSelectionDisplayMode {
public static final int FLAG_PUSH = 1;
public static final int FLAG_SMS = 1 << 1;
public static final int FLAG_ACTIVE_GROUPS = 1 << 2;
public static final int FLAG_INACTIVE_GROUPS = 1 << 3;
public static final int FLAG_SELF = 1 << 4;
public static final int FLAG_BLOCK = 1 << 5;
public static final int FLAG_HIDE_GROUPS_V1 = 1 << 5;
public static final int FLAG_HIDE_NEW = 1 << 6;
public static final int FLAG_HIDE_RECENT_HEADER = 1 << 7;
public static final int FLAG_GROUPS_AFTER_CONTACTS = 1 << 8;
public static final int FLAG_ALL = FLAG_PUSH | FLAG_SMS | FLAG_ACTIVE_GROUPS | FLAG_INACTIVE_GROUPS | FLAG_SELF;
}

View file

@ -1,448 +0,0 @@
/**
* Copyright (C) 2014 Open Whisper Systems
* <p>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* <p>
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
* <p>
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.thoughtcrime.securesms.contacts;
import android.content.Context;
import android.database.Cursor;
import android.provider.ContactsContract;
import android.text.SpannableString;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.style.ForegroundColorSpan;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.RecyclerViewFastScroller.FastScrollAdapter;
import org.thoughtcrime.securesms.contacts.ContactSelectionListAdapter.HeaderViewHolder;
import org.thoughtcrime.securesms.contacts.ContactSelectionListAdapter.ViewHolder;
import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.signal.core.util.CharacterIterable;
import org.signal.core.util.CursorUtil;
import org.thoughtcrime.securesms.util.StickyHeaderDecoration.StickyHeaderAdapter;
import org.thoughtcrime.securesms.util.Util;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
/**
* List adapter to display all contacts and their related information
*
* @author Jake McGinty
*/
public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewHolder>
implements FastScrollAdapter,
StickyHeaderAdapter<HeaderViewHolder>
{
@SuppressWarnings("unused")
private final static String TAG = Log.tag(ContactSelectionListAdapter.class);
private static final int VIEW_TYPE_CONTACT = 0;
private static final int VIEW_TYPE_DIVIDER = 1;
public static final int PAYLOAD_SELECTION_CHANGE = 1;
private final boolean multiSelect;
private final LayoutInflater layoutInflater;
private final ItemClickListener clickListener;
private final GlideRequests glideRequests;
private final Set<RecipientId> currentContacts;
private final int checkboxResource;
private final SelectedContactSet selectedContacts = new SelectedContactSet();
public void clearSelectedContacts() {
selectedContacts.clear();
}
public boolean isSelectedContact(@NonNull SelectedContact contact) {
return selectedContacts.contains(contact);
}
public void addSelectedContact(@NonNull SelectedContact contact) {
if (!selectedContacts.add(contact)) {
Log.i(TAG, "Contact was already selected, possibly by another identifier");
}
}
public void removeFromSelectedContacts(@NonNull SelectedContact selectedContact) {
int removed = selectedContacts.remove(selectedContact);
Log.i(TAG, String.format(Locale.US, "Removed %d selected contacts that matched", removed));
}
public abstract static class ViewHolder extends RecyclerView.ViewHolder {
public ViewHolder(View itemView) {
super(itemView);
}
public abstract void bind(@NonNull GlideRequests glideRequests, @Nullable RecipientId recipientId, int type, String name, String number, String label, String about, boolean checkboxVisible);
public abstract void unbind(@NonNull GlideRequests glideRequests);
public abstract void setChecked(boolean checked);
public void animateChecked(boolean checked) {
// Intentionally empty.
}
public abstract void setEnabled(boolean enabled);
public void setLetterHeaderCharacter(@Nullable String letterHeaderCharacter) {
// Intentionally empty.
}
}
public static class ContactViewHolder extends ViewHolder implements LetterHeaderDecoration.LetterHeaderItem {
private String letterHeader;
ContactViewHolder(@NonNull final View itemView,
@Nullable final ItemClickListener clickListener)
{
super(itemView);
itemView.setOnClickListener(v -> {
if (clickListener != null) clickListener.onItemClick(getView());
});
itemView.setOnLongClickListener(v -> {
if (clickListener != null) {
return clickListener.onItemLongClick(getView());
} else {
return false;
}
});
}
public ContactSelectionListItem getView() {
return (ContactSelectionListItem) itemView;
}
public void bind(@NonNull GlideRequests glideRequests, @Nullable RecipientId recipientId, int type, String name, String number, String label, String about, boolean checkBoxVisible) {
getView().set(glideRequests, recipientId, type, name, number, label, about, checkBoxVisible);
}
@Override
public void unbind(@NonNull GlideRequests glideRequests) {
getView().unbind();
}
@Override
public void setChecked(boolean checked) {
getView().setChecked(checked, false);
}
@Override
public void animateChecked(boolean checked) {
getView().setChecked(checked, true);
}
@Override
public void setEnabled(boolean enabled) {
getView().setEnabled(enabled);
}
@Override
public @Nullable String getHeaderLetter() {
return letterHeader;
}
@Override
public void setLetterHeaderCharacter(@Nullable String letterHeaderCharacter) {
this.letterHeader = letterHeaderCharacter;
}
}
public static class DividerViewHolder extends ViewHolder {
private final TextView label;
DividerViewHolder(View itemView) {
super(itemView);
this.label = itemView.findViewById(R.id.label);
}
@Override
public void bind(@NonNull GlideRequests glideRequests, @Nullable RecipientId recipientId, int type, String name, String number, String label, String about, boolean checkboxVisible) {
this.label.setText(name);
}
@Override
public void unbind(@NonNull GlideRequests glideRequests) {}
@Override
public void setChecked(boolean checked) {}
@Override
public void setEnabled(boolean enabled) {}
}
static class HeaderViewHolder extends RecyclerView.ViewHolder {
HeaderViewHolder(View itemView) {
super(itemView);
}
}
public ContactSelectionListAdapter(@NonNull Context context,
@NonNull GlideRequests glideRequests,
@Nullable Cursor cursor,
@Nullable ItemClickListener clickListener,
boolean multiSelect,
@NonNull Set<RecipientId> currentContacts,
int checkboxResource)
{
super(context, cursor);
this.layoutInflater = LayoutInflater.from(context);
this.glideRequests = glideRequests;
this.multiSelect = multiSelect;
this.clickListener = clickListener;
this.currentContacts = currentContacts;
this.checkboxResource = checkboxResource;
}
@Override
public long getHeaderId(int i) {
if (!isActiveCursor()) return -1;
else if (i == -1) return -1;
int contactType = getContactType(i);
if (contactType == ContactRepository.DIVIDER_TYPE) return -1;
return Util.hashCode(getHeaderString(i), getContactType(i));
}
@Override
public ViewHolder onCreateItemViewHolder(ViewGroup parent, int viewType) {
if (viewType == VIEW_TYPE_CONTACT) {
View view = layoutInflater.inflate(R.layout.contact_selection_list_item, parent, false);
view.findViewById(R.id.check_box).setBackgroundResource(checkboxResource);
return new ContactViewHolder(view, clickListener);
} else {
return new DividerViewHolder(layoutInflater.inflate(R.layout.contact_selection_list_divider, parent, false));
}
}
@Override
public void onBindItemViewHolder(ViewHolder viewHolder, @NonNull Cursor cursor) {
String rawId = CursorUtil.requireString(cursor, ContactRepository.ID_COLUMN);
RecipientId id = rawId != null ? RecipientId.from(rawId) : null;
int contactType = CursorUtil.requireInt(cursor, ContactRepository.CONTACT_TYPE_COLUMN);
String name = CursorUtil.requireString(cursor, ContactRepository.NAME_COLUMN);
String number = CursorUtil.requireString(cursor, ContactRepository.NUMBER_COLUMN);
int numberType = CursorUtil.requireInt(cursor, ContactRepository.NUMBER_TYPE_COLUMN);
String about = CursorUtil.requireString(cursor, ContactRepository.ABOUT_COLUMN);
String label = CursorUtil.requireString(cursor, ContactRepository.LABEL_COLUMN);
String labelText = ContactsContract.CommonDataKinds.Phone.getTypeLabel(getContext().getResources(),
numberType, label).toString();
boolean currentContact = currentContacts.contains(id);
viewHolder.unbind(glideRequests);
viewHolder.bind(glideRequests, id, contactType, name, number, labelText, about, multiSelect || currentContact);
viewHolder.setEnabled(true);
if (currentContact) {
viewHolder.setChecked(true);
viewHolder.setEnabled(false);
} else if (numberType == ContactRepository.NEW_USERNAME_TYPE) {
viewHolder.setChecked(selectedContacts.contains(SelectedContact.forUsername(id, number)));
} else {
viewHolder.setChecked(selectedContacts.contains(SelectedContact.forPhone(id, number)));
}
if (isContactRow(contactType)) {
int position = cursor.getPosition();
if (position == 0) {
viewHolder.setLetterHeaderCharacter(getHeaderLetterForDisplayName(cursor));
} else {
cursor.moveToPrevious();
int previousRowContactType = CursorUtil.requireInt(cursor, ContactRepository.CONTACT_TYPE_COLUMN);
if (!isContactRow(previousRowContactType)) {
cursor.moveToNext();
viewHolder.setLetterHeaderCharacter(getHeaderLetterForDisplayName(cursor));
} else {
String previousHeaderLetter = getHeaderLetterForDisplayName(cursor);
cursor.moveToNext();
String newHeaderLetter = getHeaderLetterForDisplayName(cursor);
if (Objects.equals(previousHeaderLetter, newHeaderLetter)) {
viewHolder.setLetterHeaderCharacter(null);
} else {
viewHolder.setLetterHeaderCharacter(newHeaderLetter);
}
}
}
}
}
private boolean isContactRow(int contactType) {
return (contactType & (ContactRepository.NEW_PHONE_TYPE | ContactRepository.NEW_USERNAME_TYPE | ContactRepository.DIVIDER_TYPE)) == 0;
}
private @Nullable String getHeaderLetterForDisplayName(@NonNull Cursor cursor) {
String name = CursorUtil.requireString(cursor, ContactRepository.NAME_COLUMN);
if (name == null) {
return null;
}
Iterator<String> characterIterator = new CharacterIterable(name).iterator();
if (!TextUtils.isEmpty(name) && characterIterator.hasNext()) {
String next = characterIterator.next();
if (Character.isLetter(next.codePointAt(0))) {
return next.toUpperCase();
} else {
return "#";
}
} else {
return null;
}
}
@Override
protected void onBindItemViewHolder(ViewHolder viewHolder, @NonNull Cursor cursor, @NonNull List<Object> payloads) {
if (!arePayloadsValid(payloads)) {
throw new AssertionError();
}
String rawId = CursorUtil.requireString(cursor, ContactRepository.ID_COLUMN);
RecipientId id = rawId != null ? RecipientId.from(rawId) : null;
int numberType = CursorUtil.requireInt(cursor, ContactRepository.NUMBER_TYPE_COLUMN);
String number = CursorUtil.requireString(cursor, ContactRepository.NUMBER_COLUMN);
viewHolder.setEnabled(true);
if (currentContacts.contains(id)) {
viewHolder.animateChecked(true);
viewHolder.setEnabled(false);
} else if (numberType == ContactRepository.NEW_USERNAME_TYPE) {
viewHolder.animateChecked(selectedContacts.contains(SelectedContact.forUsername(id, number)));
} else {
viewHolder.animateChecked(selectedContacts.contains(SelectedContact.forPhone(id, number)));
}
}
@Override
public int getItemViewType(@NonNull Cursor cursor) {
if (CursorUtil.requireInt(cursor, ContactRepository.CONTACT_TYPE_COLUMN) == ContactRepository.DIVIDER_TYPE) {
return VIEW_TYPE_DIVIDER;
} else {
return VIEW_TYPE_CONTACT;
}
}
@Override
public HeaderViewHolder onCreateHeaderViewHolder(ViewGroup parent, int position, int type) {
return new HeaderViewHolder(LayoutInflater.from(getContext()).inflate(R.layout.contact_selection_recyclerview_header, parent, false));
}
@Override
public void onBindHeaderViewHolder(HeaderViewHolder viewHolder, int position, int type) {
((TextView) viewHolder.itemView).setText(getSpannedHeaderString(position));
}
@Override
protected boolean arePayloadsValid(@NonNull List<Object> payloads) {
return payloads.size() == 1 && payloads.get(0).equals(PAYLOAD_SELECTION_CHANGE);
}
@Override
public void onItemViewRecycled(ViewHolder holder) {
holder.unbind(glideRequests);
}
@Override
public CharSequence getBubbleText(int position) {
return getHeaderString(position);
}
public List<SelectedContact> getSelectedContacts() {
return selectedContacts.getContacts();
}
public int getSelectedContactsCount() {
return selectedContacts.size();
}
public int getCurrentContactsCount() {
return currentContacts.size();
}
private CharSequence getSpannedHeaderString(int position) {
final String headerString = getHeaderString(position);
if (isPush(position)) {
SpannableString spannable = new SpannableString(headerString);
spannable.setSpan(new ForegroundColorSpan(getContext().getResources().getColor(R.color.core_ultramarine)), 0, headerString.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
return spannable;
} else {
return headerString;
}
}
private @NonNull String getHeaderString(int position) {
int contactType = getContactType(position);
if ((contactType & ContactRepository.RECENT_TYPE) > 0 || contactType == ContactRepository.DIVIDER_TYPE) {
return " ";
}
Cursor cursor = getCursorAtPositionOrThrow(position);
String letter = CursorUtil.requireString(cursor, ContactRepository.NAME_COLUMN);
if (letter != null) {
letter = letter.trim();
if (letter.length() > 0) {
char firstChar = letter.charAt(0);
if (Character.isLetterOrDigit(firstChar)) {
return String.valueOf(Character.toUpperCase(firstChar));
}
}
}
return "#";
}
private int getContactType(int position) {
final Cursor cursor = getCursorAtPositionOrThrow(position);
return cursor.getInt(cursor.getColumnIndexOrThrow(ContactRepository.CONTACT_TYPE_COLUMN));
}
private boolean isPush(int position) {
return getContactType(position) == ContactRepository.PUSH_TYPE;
}
public interface ItemClickListener {
void onItemClick(ContactSelectionListItem item);
boolean onItemLongClick(ContactSelectionListItem item);
}
}

View file

@ -1,351 +0,0 @@
/*
* Copyright (C) 2013-2017 Open Whisper Systems
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.thoughtcrime.securesms.contacts;
import android.content.Context;
import android.database.Cursor;
import android.database.MatrixCursor;
import androidx.annotation.NonNull;
import org.signal.core.util.CursorUtil;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.database.GroupTable;
import org.thoughtcrime.securesms.database.RecipientTable;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.ThreadTable;
import org.thoughtcrime.securesms.database.model.GroupRecord;
import org.thoughtcrime.securesms.database.model.ThreadRecord;
import org.thoughtcrime.securesms.phonenumbers.NumberUtil;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.UsernameUtil;
import org.whispersystems.signalservice.internal.util.Util;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* CursorLoader that initializes a ContactsDatabase instance
*
* @author Jake McGinty
*/
public class ContactsCursorLoader extends AbstractContactsCursorLoader {
private static final String TAG = Log.tag(ContactsCursorLoader.class);
public static final class DisplayMode {
public static final int FLAG_PUSH = 1;
public static final int FLAG_SMS = 1 << 1;
public static final int FLAG_ACTIVE_GROUPS = 1 << 2;
public static final int FLAG_INACTIVE_GROUPS = 1 << 3;
public static final int FLAG_SELF = 1 << 4;
public static final int FLAG_BLOCK = 1 << 5;
public static final int FLAG_HIDE_GROUPS_V1 = 1 << 5;
public static final int FLAG_HIDE_NEW = 1 << 6;
public static final int FLAG_HIDE_RECENT_HEADER = 1 << 7;
public static final int FLAG_GROUPS_AFTER_CONTACTS = 1 << 8;
public static final int FLAG_ALL = FLAG_PUSH | FLAG_SMS | FLAG_ACTIVE_GROUPS | FLAG_INACTIVE_GROUPS | FLAG_SELF;
}
private static final int RECENT_CONVERSATION_MAX = 25;
private final int mode;
private final boolean recents;
private final ContactRepository contactRepository;
private ContactsCursorLoader(@NonNull Context context, int mode, String filter, boolean recents)
{
super(context, filter);
if (flagSet(mode, DisplayMode.FLAG_INACTIVE_GROUPS) && !flagSet(mode, DisplayMode.FLAG_ACTIVE_GROUPS)) {
throw new AssertionError("Inactive group flag set, but the active group flag isn't!");
}
this.mode = mode;
this.recents = recents;
this.contactRepository = new ContactRepository(context, context.getString(R.string.note_to_self));
}
protected final List<Cursor> getUnfilteredResults() {
ArrayList<Cursor> cursorList = new ArrayList<>();
if (groupsOnly(mode)) {
addRecentGroupsSection(cursorList);
addGroupsSection(cursorList);
} else {
addRecentsSection(cursorList);
addContactsSection(cursorList);
if (addGroupsAfterContacts(mode)) {
addGroupsSection(cursorList);
}
}
return cursorList;
}
protected final List<Cursor> getFilteredResults() {
ArrayList<Cursor> cursorList = new ArrayList<>();
addContactsSection(cursorList);
addGroupsSection(cursorList);
if (!hideNewNumberOrUsername(mode)) {
addNewNumberSection(cursorList);
addUsernameSearchSection(cursorList);
}
return cursorList;
}
private void addRecentsSection(@NonNull List<Cursor> cursorList) {
if (!recents) {
return;
}
Cursor recentConversations = getRecentConversationsCursor();
if (recentConversations.getCount() > 0) {
if (!hideRecentsHeader(mode)) {
cursorList.add(ContactsCursorRows.forRecentsHeader(getContext()));
}
cursorList.add(recentConversations);
}
}
private void addContactsSection(@NonNull List<Cursor> cursorList) {
List<Cursor> contacts = getContactsCursors();
if (!isCursorListEmpty(contacts)) {
if (!getFilter().isEmpty() || recents) {
cursorList.add(ContactsCursorRows.forContactsHeader(getContext()));
}
cursorList.addAll(contacts);
}
}
private void addRecentGroupsSection(@NonNull List<Cursor> cursorList) {
if (!groupsEnabled(mode) || !recents) {
return;
}
Cursor groups = getRecentConversationsCursor(true);
if (groups.getCount() > 0) {
if (!hideRecentsHeader(mode)) {
cursorList.add(ContactsCursorRows.forRecentsHeader(getContext()));
}
cursorList.add(groups);
}
}
private void addGroupsSection(@NonNull List<Cursor> cursorList) {
if (!groupsEnabled(mode)) {
return;
}
Cursor groups = getGroupsCursor();
if (groups.getCount() > 0) {
cursorList.add(ContactsCursorRows.forGroupsHeader(getContext()));
cursorList.add(groups);
}
}
private void addNewNumberSection(@NonNull List<Cursor> cursorList) {
if (FeatureFlags.usernames() && NumberUtil.isVisuallyValidNumberOrEmail(getFilter())) {
cursorList.add(ContactsCursorRows.forPhoneNumberSearchHeader(getContext()));
cursorList.add(getNewNumberCursor());
} else if (!FeatureFlags.usernames() && NumberUtil.isValidSmsOrEmail(getFilter())) {
cursorList.add(ContactsCursorRows.forPhoneNumberSearchHeader(getContext()));
cursorList.add(getNewNumberCursor());
}
}
private void addUsernameSearchSection(@NonNull List<Cursor> cursorList) {
if (FeatureFlags.usernames() && UsernameUtil.isValidUsernameForSearch(getFilter())) {
cursorList.add(ContactsCursorRows.forUsernameSearchHeader(getContext()));
cursorList.add(getUsernameSearchCursor());
}
}
private Cursor getRecentConversationsCursor() {
return getRecentConversationsCursor(false);
}
private Cursor getRecentConversationsCursor(boolean groupsOnly) {
ThreadTable threadTable = SignalDatabase.threads();
MatrixCursor recentConversations = ContactsCursorRows.createMatrixCursor(RECENT_CONVERSATION_MAX);
try (Cursor rawConversations = threadTable.getRecentConversationList(RECENT_CONVERSATION_MAX, flagSet(mode, DisplayMode.FLAG_INACTIVE_GROUPS), false, groupsOnly, hideGroupsV1(mode), !smsEnabled(mode), false)) {
ThreadTable.Reader reader = threadTable.readerFor(rawConversations);
ThreadRecord threadRecord;
while ((threadRecord = reader.getNext()) != null) {
recentConversations.addRow(ContactsCursorRows.forRecipient(getContext(), threadRecord.getRecipient()));
}
}
return recentConversations;
}
private List<Cursor> getContactsCursors() {
List<Cursor> cursorList = new ArrayList<>(2);
if (pushEnabled(mode) && smsEnabled(mode)) {
cursorList.add(contactRepository.queryNonGroupContacts(getFilter(), selfEnabled(mode)));
} else if (pushEnabled(mode)) {
cursorList.add(contactRepository.querySignalContacts(getFilter(), selfEnabled(mode)));
} else if (smsEnabled(mode)) {
cursorList.add(contactRepository.queryNonSignalContacts(getFilter()));
}
return cursorList;
}
private Cursor getGroupsCursor() {
MatrixCursor groupContacts = ContactsCursorRows.createMatrixCursor();
Map<RecipientId, GroupRecord> groups = new LinkedHashMap<>();
try (GroupTable.Reader reader = SignalDatabase.groups().queryGroupsByTitle(getFilter(), flagSet(mode, DisplayMode.FLAG_INACTIVE_GROUPS), hideGroupsV1(mode), !smsEnabled(mode))) {
GroupRecord groupRecord;
while ((groupRecord = reader.getNext()) != null) {
groups.put(groupRecord.getRecipientId(), groupRecord);
}
}
if (getFilter() != null && !Util.isEmpty(getFilter())) {
Set<RecipientId> filteredContacts = new HashSet<>();
try (Cursor cursor = SignalDatabase.recipients().queryAllContacts(getFilter())) {
while (cursor != null && cursor.moveToNext()) {
filteredContacts.add(RecipientId.from(CursorUtil.requireString(cursor, RecipientTable.ID)));
}
}
try (GroupTable.Reader reader = SignalDatabase.groups().queryGroupsByMembership(filteredContacts, flagSet(mode, DisplayMode.FLAG_INACTIVE_GROUPS), hideGroupsV1(mode), !smsEnabled(mode))) {
GroupRecord groupRecord;
while ((groupRecord = reader.getNext()) != null) {
groups.put(groupRecord.getRecipientId(), groupRecord);
}
}
}
for (GroupRecord groupRecord : groups.values()) {
groupContacts.addRow(ContactsCursorRows.forGroup(groupRecord));
}
return groupContacts;
}
private Cursor getNewNumberCursor() {
return ContactsCursorRows.forNewNumber(getUnknownContactTitle(), getFilter());
}
private Cursor getUsernameSearchCursor() {
return ContactsCursorRows.forUsernameSearch(getFilter());
}
private String getUnknownContactTitle() {
if (blockUser(mode)) {
return getContext().getString(R.string.contact_selection_list__unknown_contact_block);
} else if (newConversation(mode)) {
return getContext().getString(R.string.contact_selection_list__unknown_contact);
} else {
return getContext().getString(R.string.contact_selection_list__unknown_contact_add_to_group);
}
}
private static boolean isCursorListEmpty(List<Cursor> list) {
int sum = 0;
for (Cursor cursor : list) {
sum += cursor.getCount();
}
return sum == 0;
}
private static boolean selfEnabled(int mode) {
return flagSet(mode, DisplayMode.FLAG_SELF);
}
private static boolean blockUser(int mode) {
return flagSet(mode, DisplayMode.FLAG_BLOCK);
}
private static boolean newConversation(int mode) {
return groupsEnabled(mode);
}
private static boolean pushEnabled(int mode) {
return flagSet(mode, DisplayMode.FLAG_PUSH);
}
private static boolean smsEnabled(int mode) {
return flagSet(mode, DisplayMode.FLAG_SMS);
}
private static boolean groupsEnabled(int mode) {
return flagSet(mode, DisplayMode.FLAG_ACTIVE_GROUPS);
}
private static boolean groupsOnly(int mode) {
return mode == DisplayMode.FLAG_ACTIVE_GROUPS;
}
private static boolean hideGroupsV1(int mode) {
return flagSet(mode, DisplayMode.FLAG_HIDE_GROUPS_V1);
}
private static boolean hideNewNumberOrUsername(int mode) {
return flagSet(mode, DisplayMode.FLAG_HIDE_NEW);
}
private static boolean hideRecentsHeader(int mode) {
return flagSet(mode, DisplayMode.FLAG_HIDE_RECENT_HEADER);
}
private static boolean addGroupsAfterContacts(int mode) {
return flagSet(mode, DisplayMode.FLAG_GROUPS_AFTER_CONTACTS);
}
private static boolean flagSet(int mode, int flag) {
return (mode & flag) > 0;
}
public static class Factory implements AbstractContactsCursorLoader.Factory {
private final Context context;
private final int displayMode;
private final String cursorFilter;
private final boolean displayRecents;
public Factory(Context context, int displayMode, String cursorFilter, boolean displayRecents) {
this.context = context;
this.displayMode = displayMode;
this.cursorFilter = cursorFilter;
this.displayRecents = displayRecents;
}
@Override
public @NonNull AbstractContactsCursorLoader create() {
return new ContactsCursorLoader(context, displayMode, cursorFilter, displayRecents);
}
}
}

View file

@ -1,154 +0,0 @@
package org.thoughtcrime.securesms.contacts;
import android.content.Context;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.provider.ContactsContract;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.database.model.GroupRecord;
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.whispersystems.signalservice.api.util.OptionalUtil;
/**
* Helper utility for generating cursors and cursor rows for subclasses of {@link AbstractContactsCursorLoader}.
*/
public final class ContactsCursorRows {
private static final String[] CONTACT_PROJECTION = new String[]{ContactRepository.ID_COLUMN,
ContactRepository.NAME_COLUMN,
ContactRepository.NUMBER_COLUMN,
ContactRepository.NUMBER_TYPE_COLUMN,
ContactRepository.LABEL_COLUMN,
ContactRepository.CONTACT_TYPE_COLUMN,
ContactRepository.ABOUT_COLUMN};
/**
* Create a {@link MatrixCursor} with the proper projection for a subclass of {@link AbstractContactsCursorLoader}
*/
public static @NonNull MatrixCursor createMatrixCursor() {
return new MatrixCursor(CONTACT_PROJECTION);
}
/**
* Create a {@link MatrixCursor} with the proper projection for a subclass of {@link AbstractContactsCursorLoader}
*
* @param initialCapacity The initial capacity to hand to the {@link MatrixCursor}
*/
public static @NonNull MatrixCursor createMatrixCursor(int initialCapacity) {
return new MatrixCursor(CONTACT_PROJECTION, initialCapacity);
}
/**
* Create a row for a contacts cursor based off the given recipient.
*/
public static @NonNull Object[] forRecipient(@NonNull Context context, @NonNull Recipient recipient) {
String stringId = recipient.isGroup() ? recipient.requireGroupId().toString()
: OptionalUtil.or(recipient.getE164().map(PhoneNumberFormatter::prettyPrint), recipient.getEmail()).orElse("");
return new Object[]{recipient.getId().serialize(),
recipient.getDisplayName(context),
stringId,
ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE,
"",
ContactRepository.RECENT_TYPE | (recipient.isRegistered() && !recipient.isForceSmsSelection() ? ContactRepository.PUSH_TYPE : 0),
recipient.getCombinedAboutAndEmoji()};
}
/**
* Create a row for a contacts cursor based off the given system contact.
*/
public static @NonNull Object[] forNonPushContact(@NonNull Cursor systemContactCursor) {
return new Object[]{systemContactCursor.getLong(systemContactCursor.getColumnIndexOrThrow(ContactRepository.ID_COLUMN)),
systemContactCursor.getString(systemContactCursor.getColumnIndexOrThrow(ContactRepository.NAME_COLUMN)),
systemContactCursor.getString(systemContactCursor.getColumnIndexOrThrow(ContactRepository.NUMBER_COLUMN)),
systemContactCursor.getString(systemContactCursor.getColumnIndexOrThrow(ContactRepository.NUMBER_TYPE_COLUMN)),
systemContactCursor.getString(systemContactCursor.getColumnIndexOrThrow(ContactRepository.LABEL_COLUMN)),
ContactRepository.NORMAL_TYPE,
""};
}
/**
* Create a row for a contacts cursor based off the given group record.
*/
public static @NonNull Object[] forGroup(@NonNull GroupRecord groupRecord) {
return new Object[]{groupRecord.getRecipientId().serialize(),
groupRecord.getTitle(),
groupRecord.getId(),
ContactsContract.CommonDataKinds.Phone.TYPE_CUSTOM,
"",
ContactRepository.NORMAL_TYPE,
""};
}
/**
* Create a row for a contacts cursor for a new number the user is entering or has entered.
*/
public static @NonNull MatrixCursor forNewNumber(@NonNull String unknownContactTitle, @NonNull String filter) {
MatrixCursor matrixCursor = createMatrixCursor(1);
matrixCursor.addRow(new Object[]{null,
unknownContactTitle,
filter,
ContactsContract.CommonDataKinds.Phone.TYPE_CUSTOM,
"\u21e2",
ContactRepository.NEW_PHONE_TYPE,
""});
return matrixCursor;
}
/**
* Create a row for a contacts cursor for a username the user is entering or has entered.
*/
public static @NonNull MatrixCursor forUsernameSearch(@NonNull String filter) {
MatrixCursor matrixCursor = createMatrixCursor(1);
matrixCursor.addRow(new Object[]{null,
null,
filter,
ContactsContract.CommonDataKinds.Phone.TYPE_CUSTOM,
"\u21e2",
ContactRepository.NEW_USERNAME_TYPE,
""});
return matrixCursor;
}
public static @NonNull MatrixCursor forUsernameSearchHeader(@NonNull Context context) {
return forHeader(context.getString(R.string.ContactsCursorLoader_find_by_username));
}
public static @NonNull MatrixCursor forPhoneNumberSearchHeader(@NonNull Context context) {
return forHeader(context.getString(R.string.ContactsCursorLoader_phone_number_search));
}
public static @NonNull MatrixCursor forGroupsHeader(@NonNull Context context) {
return forHeader(context.getString(R.string.ContactsCursorLoader_groups));
}
public static @NonNull MatrixCursor forRecentsHeader(@NonNull Context context) {
return forHeader(context.getString(R.string.ContactsCursorLoader_recent_chats));
}
public static @NonNull MatrixCursor forContactsHeader(@NonNull Context context) {
return forHeader(context.getString(R.string.ContactsCursorLoader_contacts));
}
public static @NonNull MatrixCursor forHeader(@NonNull String name) {
MatrixCursor matrixCursor = createMatrixCursor(1);
matrixCursor.addRow(new Object[]{null,
name,
"",
ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE,
"",
ContactRepository.DIVIDER_TYPE,
""});
return matrixCursor;
}
}

View file

@ -5,6 +5,8 @@ import android.content.Context;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey;
import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.RecipientId;
@ -46,10 +48,30 @@ public final class SelectedContact {
} }
} }
public @Nullable RecipientId getRecipientId() {
return recipientId;
}
public @Nullable String getNumber() {
return number;
}
public boolean hasUsername() { public boolean hasUsername() {
return username != null; return username != null;
} }
public @NonNull ContactSearchKey toContactSearchKey() {
if (recipientId != null) {
return new ContactSearchKey.RecipientSearchKey(recipientId, false);
} else if (number != null) {
return new ContactSearchKey.UnknownRecipientKey(ContactSearchConfiguration.SectionKey.PHONE_NUMBER, number);
} else if (username != null) {
return new ContactSearchKey.UnknownRecipientKey(ContactSearchConfiguration.SectionKey.USERNAME, username);
} else {
throw new IllegalStateException("Nothing to map!");
}
}
/** /**
* Returns true when non-null recipient ids match, and false if not. * Returns true when non-null recipient ids match, and false if not.
* <p> * <p>

View file

@ -1,15 +1,18 @@
package org.thoughtcrime.securesms.contacts.paged package org.thoughtcrime.securesms.contacts.paged
import android.content.Context
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.CheckBox import android.widget.CheckBox
import android.widget.TextView import android.widget.TextView
import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialButton
import org.signal.core.util.BreakIteratorCompat
import org.signal.core.util.dp import org.signal.core.util.dp
import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.components.AvatarImageView import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.components.FromTextView import org.thoughtcrime.securesms.components.FromTextView
import org.thoughtcrime.securesms.components.RecyclerViewFastScroller.FastScrollAdapter
import org.thoughtcrime.securesms.components.menu.ActionItem import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.menu.SignalContextMenu import org.thoughtcrime.securesms.components.menu.SignalContextMenu
import org.thoughtcrime.securesms.contacts.LetterHeaderDecoration import org.thoughtcrime.securesms.contacts.LetterHeaderDecoration
@ -31,17 +34,35 @@ import org.thoughtcrime.securesms.util.visible
*/ */
@Suppress("LeakingThis") @Suppress("LeakingThis")
open class ContactSearchAdapter( open class ContactSearchAdapter(
private val context: Context,
fixedContacts: Set<ContactSearchKey>,
displayCheckBox: Boolean, displayCheckBox: Boolean,
displaySmsTag: DisplaySmsTag, displaySmsTag: DisplaySmsTag,
displayPhoneNumber: DisplayPhoneNumber,
onClickCallbacks: ClickCallbacks, onClickCallbacks: ClickCallbacks,
longClickCallbacks: LongClickCallbacks,
storyContextMenuCallbacks: StoryContextMenuCallbacks storyContextMenuCallbacks: StoryContextMenuCallbacks
) : PagingMappingAdapter<ContactSearchKey>() { ) : PagingMappingAdapter<ContactSearchKey>(), FastScrollAdapter {
init { init {
registerStoryItems(this, displayCheckBox, onClickCallbacks::onStoryClicked, storyContextMenuCallbacks) registerStoryItems(this, displayCheckBox, onClickCallbacks::onStoryClicked, storyContextMenuCallbacks)
registerKnownRecipientItems(this, displayCheckBox, displaySmsTag, onClickCallbacks::onKnownRecipientClicked) registerKnownRecipientItems(this, fixedContacts, displayCheckBox, displaySmsTag, displayPhoneNumber, onClickCallbacks::onKnownRecipientClicked, longClickCallbacks::onKnownRecipientLongClick)
registerHeaders(this) registerHeaders(this)
registerExpands(this, onClickCallbacks::onExpandClicked) registerExpands(this, onClickCallbacks::onExpandClicked)
registerFactory(UnknownRecipientModel::class.java, LayoutFactory({ UnknownRecipientViewHolder(it, onClickCallbacks::onUnknownRecipientClicked, displayCheckBox) }, R.layout.contact_search_unknown_item))
}
override fun getBubbleText(position: Int): CharSequence {
val model = getItem(position)
return if (model is FastScrollCharacterProvider) {
model.getFastScrollCharacter(context)
} else {
" "
}
}
interface FastScrollCharacterProvider {
fun getFastScrollCharacter(context: Context): CharSequence
} }
companion object { companion object {
@ -59,13 +80,16 @@ open class ContactSearchAdapter(
fun registerKnownRecipientItems( fun registerKnownRecipientItems(
mappingAdapter: MappingAdapter, mappingAdapter: MappingAdapter,
fixedContacts: Set<ContactSearchKey>,
displayCheckBox: Boolean, displayCheckBox: Boolean,
displaySmsTag: DisplaySmsTag, displaySmsTag: DisplaySmsTag,
recipientListener: OnClickedCallback<ContactSearchData.KnownRecipient> displayPhoneNumber: DisplayPhoneNumber,
recipientListener: OnClickedCallback<ContactSearchData.KnownRecipient>,
recipientLongClickCallback: OnLongClickedCallback<ContactSearchData.KnownRecipient>
) { ) {
mappingAdapter.registerFactory( mappingAdapter.registerFactory(
RecipientModel::class.java, RecipientModel::class.java,
LayoutFactory({ KnownRecipientViewHolder(it, displayCheckBox, displaySmsTag, recipientListener) }, R.layout.contact_search_item) LayoutFactory({ KnownRecipientViewHolder(it, fixedContacts, displayCheckBox, displaySmsTag, displayPhoneNumber, recipientListener, recipientLongClickCallback) }, R.layout.contact_search_item)
) )
} }
@ -97,6 +121,7 @@ open class ContactSearchAdapter(
is ContactSearchData.Thread -> ThreadModel(it) is ContactSearchData.Thread -> ThreadModel(it)
is ContactSearchData.Empty -> EmptyModel(it) is ContactSearchData.Empty -> EmptyModel(it)
is ContactSearchData.GroupWithMembers -> GroupWithMembersModel(it) is ContactSearchData.GroupWithMembers -> GroupWithMembersModel(it)
is ContactSearchData.UnknownRecipient -> UnknownRecipientModel(it)
} }
} }
) )
@ -161,6 +186,7 @@ open class ContactSearchAdapter(
context.resources.getQuantityString(R.plurals.ContactSearchItems__my_story_s_dot_d_viewers, count, presentPrivacyMode(model.story.privacyMode), count) context.resources.getQuantityString(R.plurals.ContactSearchItems__my_story_s_dot_d_viewers, count, presentPrivacyMode(model.story.privacyMode), count)
} }
} }
else -> context.resources.getQuantityString(R.plurals.ContactSearchItems__custom_story_d_viewers, count, count) else -> context.resources.getQuantityString(R.plurals.ContactSearchItems__custom_story_d_viewers, count, count)
} }
} }
@ -242,7 +268,32 @@ open class ContactSearchAdapter(
/** /**
* Recipient model * Recipient model
*/ */
class RecipientModel(val knownRecipient: ContactSearchData.KnownRecipient, val isSelected: Boolean, val shortSummary: Boolean) : MappingModel<RecipientModel> { class RecipientModel(
val knownRecipient: ContactSearchData.KnownRecipient,
val isSelected: Boolean,
val shortSummary: Boolean
) : MappingModel<RecipientModel>, FastScrollCharacterProvider {
override fun getFastScrollCharacter(context: Context): CharSequence {
val name = if (knownRecipient.recipient.isSelf) {
context.getString(R.string.note_to_self)
} else {
knownRecipient.recipient.getDisplayName(context)
}
var letter = BreakIteratorCompat.getInstance().apply { setText(name) }.take(1)
if (letter != null) {
letter = letter.trim { it <= ' ' }
if (letter.isNotEmpty()) {
val firstChar = letter[0]
if (Character.isLetterOrDigit(firstChar)) {
return firstChar.uppercaseChar().toString()
}
}
}
return "#"
}
override fun areItemsTheSame(newItem: RecipientModel): Boolean { override fun areItemsTheSame(newItem: RecipientModel): Boolean {
return newItem.knownRecipient == knownRecipient return newItem.knownRecipient == knownRecipient
@ -261,11 +312,47 @@ open class ContactSearchAdapter(
} }
} }
class UnknownRecipientModel(val data: ContactSearchData.UnknownRecipient) : MappingModel<UnknownRecipientModel> {
override fun areItemsTheSame(newItem: UnknownRecipientModel): Boolean = true
override fun areContentsTheSame(newItem: UnknownRecipientModel): Boolean = data == newItem.data
}
private class UnknownRecipientViewHolder(
itemView: View,
private val onClick: OnClickedCallback<ContactSearchData.UnknownRecipient>,
private val displayCheckBox: Boolean
) : MappingViewHolder<UnknownRecipientModel>(itemView) {
private val checkbox: CheckBox = itemView.findViewById(R.id.check_box)
private val name: FromTextView = itemView.findViewById(R.id.name)
private val number: TextView = itemView.findViewById(R.id.number)
override fun bind(model: UnknownRecipientModel) {
checkbox.visible = displayCheckBox
checkbox.isSelected = false
name.setText(
when (model.data.mode) {
ContactSearchConfiguration.NewRowMode.NEW_CONVERSATION -> R.string.contact_selection_list__unknown_contact
ContactSearchConfiguration.NewRowMode.BLOCK -> R.string.contact_selection_list__unknown_contact_block
ContactSearchConfiguration.NewRowMode.ADD_TO_GROUP -> R.string.contact_selection_list__unknown_contact_add_to_group
}
)
number.text = model.data.query
itemView.setOnClickListener {
onClick.onClicked(itemView, model.data, false)
}
}
}
private class KnownRecipientViewHolder( private class KnownRecipientViewHolder(
itemView: View, itemView: View,
private val fixedContacts: Set<ContactSearchKey>,
displayCheckBox: Boolean, displayCheckBox: Boolean,
displaySmsTag: DisplaySmsTag, displaySmsTag: DisplaySmsTag,
onClick: OnClickedCallback<ContactSearchData.KnownRecipient> private val displayPhoneNumber: DisplayPhoneNumber,
onClick: OnClickedCallback<ContactSearchData.KnownRecipient>,
private val onLongClick: OnLongClickedCallback<ContactSearchData.KnownRecipient>
) : BaseRecipientViewHolder<RecipientModel, ContactSearchData.KnownRecipient>(itemView, displayCheckBox, displaySmsTag, onClick), LetterHeaderDecoration.LetterHeaderItem { ) : BaseRecipientViewHolder<RecipientModel, ContactSearchData.KnownRecipient>(itemView, displayCheckBox, displaySmsTag, onClick), LetterHeaderDecoration.LetterHeaderItem {
private var headerLetter: String? = null private var headerLetter: String? = null
@ -282,6 +369,9 @@ open class ContactSearchAdapter(
val count = recipient.participantIds.size val count = recipient.participantIds.size
number.text = context.resources.getQuantityString(R.plurals.ContactSearchItems__group_d_members, count, count) number.text = context.resources.getQuantityString(R.plurals.ContactSearchItems__group_d_members, count, count)
number.visible = true number.visible = true
} else if (displayPhoneNumber == DisplayPhoneNumber.ALWAYS && recipient.hasE164()) {
number.text = recipient.requireE164()
number.visible = true
} else { } else {
super.bindNumberField(model) super.bindNumberField(model)
} }
@ -289,15 +379,22 @@ open class ContactSearchAdapter(
headerLetter = model.knownRecipient.headerLetter headerLetter = model.knownRecipient.headerLetter
} }
override fun bindCheckbox(model: RecipientModel) {
checkbox.isEnabled = !fixedContacts.contains(model.knownRecipient.contactSearchKey)
}
override fun getHeaderLetter(): String? { override fun getHeaderLetter(): String? {
return headerLetter return headerLetter
} }
override fun bindLongPress(model: RecipientModel) {
itemView.setOnLongClickListener { onLongClick.onLongClicked(itemView, model.knownRecipient) }
}
} }
/** /**
* Base Recipient View Holder * Base Recipient View Holder
*/ */
abstract class BaseRecipientViewHolder<T : MappingModel<T>, D : ContactSearchData>( abstract class BaseRecipientViewHolder<T : MappingModel<T>, D : ContactSearchData>(
itemView: View, itemView: View,
private val displayCheckBox: Boolean, private val displayCheckBox: Boolean,
@ -316,6 +413,7 @@ open class ContactSearchAdapter(
override fun bind(model: T) { override fun bind(model: T) {
checkbox.visible = displayCheckBox checkbox.visible = displayCheckBox
checkbox.isChecked = isSelected(model) checkbox.isChecked = isSelected(model)
itemView.setOnClickListener { onClick.onClicked(avatar, getData(model), isSelected(model)) } itemView.setOnClickListener { onClick.onClicked(avatar, getData(model), isSelected(model)) }
bindLongPress(model) bindLongPress(model)
@ -332,6 +430,8 @@ open class ContactSearchAdapter(
bindSmsTagField(model) bindSmsTagField(model)
} }
protected open fun bindCheckbox(model: T) = Unit
protected open fun bindAvatar(model: T) { protected open fun bindAvatar(model: T) {
avatar.setAvatar(getRecipient(model)) avatar.setAvatar(getRecipient(model))
} }
@ -444,12 +544,12 @@ open class ContactSearchAdapter(
ContactSearchConfiguration.SectionKey.RECENTS -> R.string.ContactsCursorLoader_recent_chats ContactSearchConfiguration.SectionKey.RECENTS -> R.string.ContactsCursorLoader_recent_chats
ContactSearchConfiguration.SectionKey.INDIVIDUALS -> R.string.ContactsCursorLoader_contacts ContactSearchConfiguration.SectionKey.INDIVIDUALS -> R.string.ContactsCursorLoader_contacts
ContactSearchConfiguration.SectionKey.GROUPS -> R.string.ContactsCursorLoader_groups ContactSearchConfiguration.SectionKey.GROUPS -> R.string.ContactsCursorLoader_groups
ContactSearchConfiguration.SectionKey.ARBITRARY -> error("This section does not support HEADER")
ContactSearchConfiguration.SectionKey.GROUP_MEMBERS -> R.string.ContactsCursorLoader_group_members ContactSearchConfiguration.SectionKey.GROUP_MEMBERS -> R.string.ContactsCursorLoader_group_members
ContactSearchConfiguration.SectionKey.CHATS -> R.string.ContactsCursorLoader__chats ContactSearchConfiguration.SectionKey.CHATS -> R.string.ContactsCursorLoader__chats
ContactSearchConfiguration.SectionKey.MESSAGES -> R.string.ContactsCursorLoader__messages ContactSearchConfiguration.SectionKey.MESSAGES -> R.string.ContactsCursorLoader__messages
ContactSearchConfiguration.SectionKey.GROUPS_WITH_MEMBERS -> R.string.ContactsCursorLoader_group_members ContactSearchConfiguration.SectionKey.GROUPS_WITH_MEMBERS -> R.string.ContactsCursorLoader_group_members
ContactSearchConfiguration.SectionKey.CONTACTS_WITHOUT_THREADS -> R.string.ContactsCursorLoader_contacts ContactSearchConfiguration.SectionKey.CONTACTS_WITHOUT_THREADS -> R.string.ContactsCursorLoader_contacts
else -> error("This section does not support HEADER")
} }
) )
@ -507,13 +607,33 @@ open class ContactSearchAdapter(
NEVER NEVER
} }
enum class DisplayPhoneNumber {
NEVER,
ALWAYS
}
fun interface OnClickedCallback<D : ContactSearchData> { fun interface OnClickedCallback<D : ContactSearchData> {
fun onClicked(view: View, data: D, isSelected: Boolean) fun onClicked(view: View, data: D, isSelected: Boolean)
} }
fun interface OnLongClickedCallback<D : ContactSearchData> {
fun onLongClicked(view: View, data: D): Boolean
}
interface ClickCallbacks { interface ClickCallbacks {
fun onStoryClicked(view: View, story: ContactSearchData.Story, isSelected: Boolean) fun onStoryClicked(view: View, story: ContactSearchData.Story, isSelected: Boolean)
fun onKnownRecipientClicked(view: View, knownRecipient: ContactSearchData.KnownRecipient, isSelected: Boolean) fun onKnownRecipientClicked(view: View, knownRecipient: ContactSearchData.KnownRecipient, isSelected: Boolean)
fun onExpandClicked(expand: ContactSearchData.Expand) fun onExpandClicked(expand: ContactSearchData.Expand)
fun onUnknownRecipientClicked(view: View, unknownRecipient: ContactSearchData.UnknownRecipient, isSelected: Boolean) {
throw NotImplementedError()
}
}
interface LongClickCallbacks {
fun onKnownRecipientLongClick(view: View, data: ContactSearchData.KnownRecipient): Boolean
}
class LongClickCallbacksAdapter : LongClickCallbacks {
override fun onKnownRecipientLongClick(view: View, data: ContactSearchData.KnownRecipient): Boolean = false
} }
} }

View file

@ -172,6 +172,16 @@ class ContactSearchConfiguration private constructor(
override val includeHeader: Boolean = true, override val includeHeader: Boolean = true,
override val expandConfig: ExpandConfig? = null override val expandConfig: ExpandConfig? = null
) : Section(SectionKey.CONTACTS_WITHOUT_THREADS) ) : Section(SectionKey.CONTACTS_WITHOUT_THREADS)
data class Username(val newRowMode: NewRowMode) : Section(SectionKey.USERNAME) {
override val includeHeader: Boolean = false
override val expandConfig: ExpandConfig? = null
}
data class PhoneNumber(val newRowMode: NewRowMode) : Section(SectionKey.PHONE_NUMBER) {
override val includeHeader: Boolean = false
override val expandConfig: ExpandConfig? = null
}
} }
/** /**
@ -227,7 +237,17 @@ class ContactSearchConfiguration private constructor(
/** /**
* Messages from 1:1 and Group chats * Messages from 1:1 and Group chats
*/ */
MESSAGES MESSAGES,
/**
* A row representing the search query as a phone number
*/
PHONE_NUMBER,
/**
* A row representing the search query as a username
*/
USERNAME
} }
/** /**
@ -247,6 +267,15 @@ class ContactSearchConfiguration private constructor(
ALL ALL
} }
/**
* Describes the mode for 'Username' or 'PhoneNumber'
*/
enum class NewRowMode {
NEW_CONVERSATION,
BLOCK,
ADD_TO_GROUP
}
companion object { companion object {
/** /**
* DSL Style builder function. Example: * DSL Style builder function. Example:
@ -296,11 +325,12 @@ class ContactSearchConfiguration private constructor(
addSection(Section.Arbitrary(setOf(first) + rest.toSet())) addSection(Section.Arbitrary(setOf(first) + rest.toSet()))
} }
fun groupsWithMembers( fun username(newRowMode: NewRowMode) {
includeHeader: Boolean = true, addSection(Section.Username(newRowMode))
expandConfig: ExpandConfig? = null }
) {
addSection(Section.GroupsWithMembers(includeHeader, expandConfig)) fun phone(newRowMode: NewRowMode) {
addSection(Section.PhoneNumber(newRowMode))
} }
fun addSection(section: Section) fun addSection(section: Section)

View file

@ -89,4 +89,13 @@ sealed class ContactSearchData(val contactSearchKey: ContactSearchKey) {
*/ */
@VisibleForTesting @VisibleForTesting
class TestRow(val value: Int) : ContactSearchData(ContactSearchKey.Expand(ContactSearchConfiguration.SectionKey.RECENTS)) class TestRow(val value: Int) : ContactSearchData(ContactSearchKey.Expand(ContactSearchConfiguration.SectionKey.RECENTS))
/**
* A row displaying an unknown phone number or username
*/
data class UnknownRecipient(
val sectionKey: ContactSearchConfiguration.SectionKey,
val mode: ContactSearchConfiguration.NewRowMode,
val query: String
) : ContactSearchData(ContactSearchKey.UnknownRecipientKey(sectionKey, query))
} }

View file

@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.contacts.paged
import android.os.Parcelable import android.os.Parcelable
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import org.thoughtcrime.securesms.contacts.SelectedContact
import org.thoughtcrime.securesms.groups.GroupId import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sharing.ShareContact import org.thoughtcrime.securesms.sharing.ShareContact
@ -20,11 +21,23 @@ sealed class ContactSearchKey {
open fun requireRecipientSearchKey(): RecipientSearchKey = error("This key cannot be parcelized") open fun requireRecipientSearchKey(): RecipientSearchKey = error("This key cannot be parcelized")
open fun requireSelectedContact(): SelectedContact = error("This key cannot be converted into a SelectedContact")
@Parcelize @Parcelize
data class RecipientSearchKey(val recipientId: RecipientId, val isStory: Boolean) : ContactSearchKey(), Parcelable { data class RecipientSearchKey(val recipientId: RecipientId, val isStory: Boolean) : ContactSearchKey(), Parcelable {
override fun requireRecipientSearchKey(): RecipientSearchKey = this override fun requireRecipientSearchKey(): RecipientSearchKey = this
override fun requireShareContact(): ShareContact = ShareContact(recipientId) override fun requireShareContact(): ShareContact = ShareContact(recipientId)
override fun requireSelectedContact(): SelectedContact = SelectedContact.forRecipientId(recipientId)
}
data class UnknownRecipientKey(val sectionKey: ContactSearchConfiguration.SectionKey, val query: String) : ContactSearchKey() {
override fun requireSelectedContact(): SelectedContact = when (sectionKey) {
ContactSearchConfiguration.SectionKey.USERNAME -> SelectedContact.forPhone(null, query)
ContactSearchConfiguration.SectionKey.PHONE_NUMBER -> SelectedContact.forPhone(null, query)
else -> error("Unexpected section for unknown recipient: $sectionKey")
}
} }
/** /**

View file

@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.contacts.paged package org.thoughtcrime.securesms.contacts.paged
import android.content.Context
import android.view.View import android.view.View
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
@ -22,13 +23,30 @@ import org.thoughtcrime.securesms.util.adapter.mapping.PagingMappingAdapter
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil import org.thoughtcrime.securesms.util.livedata.LiveDataUtil
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
/**
* This mediator serves as the delegate for interacting with the ContactSearch* framework.
*
* @param fragment The fragment displaying the content search results.
* @param fixedContacts Contacts which are "pre-selected" (for example, already a member of a group we're adding to)
* @param selectionLimits [SelectionLimits] describing how large the result set can be.
* @param displayCheckBox Whether or not to display checkboxes on items.
* @param displaySmsTag Whether or not to display the SMS tag on items.
* @param displayPhoneNumber Whether or not to display phone numbers on known contacts.
* @param mapStateToConfiguration Maps a [ContactSearchState] to a [ContactSearchConfiguration]
* @param callbacks Hooks to help process, filter, and react to selection
* @param performSafetyNumberChecks Whether to perform safety number checks for selected users
* @param adapterFactory A factory for creating an instance of [PagingMappingAdapter] to display items
* @param arbitraryRepository A repository for managing [ContactSearchKey.Arbitrary] data
*/
class ContactSearchMediator( class ContactSearchMediator(
private val fragment: Fragment, private val fragment: Fragment,
private val fixedContacts: Set<ContactSearchKey> = setOf(),
selectionLimits: SelectionLimits, selectionLimits: SelectionLimits,
displayCheckBox: Boolean, displayCheckBox: Boolean,
displaySmsTag: ContactSearchAdapter.DisplaySmsTag, displaySmsTag: ContactSearchAdapter.DisplaySmsTag,
displayPhoneNumber: ContactSearchAdapter.DisplayPhoneNumber,
mapStateToConfiguration: (ContactSearchState) -> ContactSearchConfiguration, mapStateToConfiguration: (ContactSearchState) -> ContactSearchConfiguration,
private val contactSelectionPreFilter: (View?, Set<ContactSearchKey>) -> Set<ContactSearchKey> = { _, s -> s }, private val callbacks: Callbacks = SimpleCallbacks(),
performSafetyNumberChecks: Boolean = true, performSafetyNumberChecks: Boolean = true,
adapterFactory: AdapterFactory = DefaultAdapterFactory, adapterFactory: AdapterFactory = DefaultAdapterFactory,
arbitraryRepository: ArbitraryRepository? = null, arbitraryRepository: ArbitraryRepository? = null,
@ -49,8 +67,11 @@ class ContactSearchMediator(
)[ContactSearchViewModel::class.java] )[ContactSearchViewModel::class.java]
val adapter = adapterFactory.create( val adapter = adapterFactory.create(
context = fragment.requireContext(),
fixedContacts = fixedContacts,
displayCheckBox = displayCheckBox, displayCheckBox = displayCheckBox,
displaySmsTag = displaySmsTag, displaySmsTag = displaySmsTag,
displayPhoneNumber = displayPhoneNumber,
callbacks = object : ContactSearchAdapter.ClickCallbacks { callbacks = object : ContactSearchAdapter.ClickCallbacks {
override fun onStoryClicked(view: View, story: ContactSearchData.Story, isSelected: Boolean) { override fun onStoryClicked(view: View, story: ContactSearchData.Story, isSelected: Boolean) {
toggleStorySelection(view, story, isSelected) toggleStorySelection(view, story, isSelected)
@ -64,6 +85,7 @@ class ContactSearchMediator(
viewModel.expandSection(expand.sectionKey) viewModel.expandSection(expand.sectionKey)
} }
}, },
longClickCallbacks = ContactSearchAdapter.LongClickCallbacksAdapter(),
storyContextMenuCallbacks = StoryContextMenuCallbacks() storyContextMenuCallbacks = StoryContextMenuCallbacks()
) )
@ -75,7 +97,9 @@ class ContactSearchMediator(
) )
dataAndSelection.observe(fragment.viewLifecycleOwner) { (data, selection) -> dataAndSelection.observe(fragment.viewLifecycleOwner) { (data, selection) ->
adapter.submitList(ContactSearchAdapter.toMappingModelList(data, selection, arbitraryRepository)) adapter.submitList(ContactSearchAdapter.toMappingModelList(data, selection, arbitraryRepository), {
callbacks.onAdapterListCommitted(data.size)
})
} }
viewModel.controller.observe(fragment.viewLifecycleOwner) { controller -> viewModel.controller.observe(fragment.viewLifecycleOwner) { controller ->
@ -98,17 +122,28 @@ class ContactSearchMediator(
} }
fun setKeysSelected(keys: Set<ContactSearchKey>) { fun setKeysSelected(keys: Set<ContactSearchKey>) {
viewModel.setKeysSelected(contactSelectionPreFilter(null, keys)) viewModel.setKeysSelected(callbacks.onBeforeContactsSelected(null, keys))
} }
fun setKeysNotSelected(keys: Set<ContactSearchKey>) { fun setKeysNotSelected(keys: Set<ContactSearchKey>) {
keys.forEach {
callbacks.onContactDeselected(null, it)
}
viewModel.setKeysNotSelected(keys) viewModel.setKeysNotSelected(keys)
} }
fun clearSelection() {
viewModel.clearSelection()
}
fun getSelectedContacts(): Set<ContactSearchKey> { fun getSelectedContacts(): Set<ContactSearchKey> {
return viewModel.getSelectedContacts() return viewModel.getSelectedContacts()
} }
fun getFixedContactsSize(): Int {
return fixedContacts.size
}
fun getSelectionState(): LiveData<Set<ContactSearchKey>> { fun getSelectionState(): LiveData<Set<ContactSearchKey>> {
return viewModel.selectionState return viewModel.selectionState
} }
@ -135,9 +170,10 @@ class ContactSearchMediator(
private fun toggleSelection(view: View, contactSearchData: ContactSearchData, isSelected: Boolean) { private fun toggleSelection(view: View, contactSearchData: ContactSearchData, isSelected: Boolean) {
return if (isSelected) { return if (isSelected) {
callbacks.onContactDeselected(view, contactSearchData.contactSearchKey)
viewModel.setKeysNotSelected(setOf(contactSearchData.contactSearchKey)) viewModel.setKeysNotSelected(setOf(contactSearchData.contactSearchKey))
} else { } else {
viewModel.setKeysSelected(contactSelectionPreFilter(view, setOf(contactSearchData.contactSearchKey))) viewModel.setKeysSelected(callbacks.onBeforeContactsSelected(view, setOf(contactSearchData.contactSearchKey)))
} }
} }
@ -171,27 +207,50 @@ class ContactSearchMediator(
} }
} }
interface Callbacks {
fun onBeforeContactsSelected(view: View?, contactSearchKeys: Set<ContactSearchKey>): Set<ContactSearchKey>
fun onContactDeselected(view: View?, contactSearchKey: ContactSearchKey)
fun onAdapterListCommitted(size: Int)
}
open class SimpleCallbacks : Callbacks {
override fun onBeforeContactsSelected(view: View?, contactSearchKeys: Set<ContactSearchKey>): Set<ContactSearchKey> {
return contactSearchKeys
}
override fun onContactDeselected(view: View?, contactSearchKey: ContactSearchKey) = Unit
override fun onAdapterListCommitted(size: Int) = Unit
}
/** /**
* Wraps the construction of a PagingMappingAdapter<ContactSearchKey> so that it can * Wraps the construction of a PagingMappingAdapter<ContactSearchKey> so that it can
* be swapped for another implementation, allow listeners to be wrapped, etc. * be swapped for another implementation, allow listeners to be wrapped, etc.
*/ */
fun interface AdapterFactory { fun interface AdapterFactory {
fun create( fun create(
context: Context,
fixedContacts: Set<ContactSearchKey>,
displayCheckBox: Boolean, displayCheckBox: Boolean,
displaySmsTag: ContactSearchAdapter.DisplaySmsTag, displaySmsTag: ContactSearchAdapter.DisplaySmsTag,
displayPhoneNumber: ContactSearchAdapter.DisplayPhoneNumber,
callbacks: ContactSearchAdapter.ClickCallbacks, callbacks: ContactSearchAdapter.ClickCallbacks,
longClickCallbacks: ContactSearchAdapter.LongClickCallbacks,
storyContextMenuCallbacks: ContactSearchAdapter.StoryContextMenuCallbacks storyContextMenuCallbacks: ContactSearchAdapter.StoryContextMenuCallbacks
): PagingMappingAdapter<ContactSearchKey> ): PagingMappingAdapter<ContactSearchKey>
} }
private object DefaultAdapterFactory : AdapterFactory { private object DefaultAdapterFactory : AdapterFactory {
override fun create( override fun create(
context: Context,
fixedContacts: Set<ContactSearchKey>,
displayCheckBox: Boolean, displayCheckBox: Boolean,
displaySmsTag: ContactSearchAdapter.DisplaySmsTag, displaySmsTag: ContactSearchAdapter.DisplaySmsTag,
displayPhoneNumber: ContactSearchAdapter.DisplayPhoneNumber,
callbacks: ContactSearchAdapter.ClickCallbacks, callbacks: ContactSearchAdapter.ClickCallbacks,
longClickCallbacks: ContactSearchAdapter.LongClickCallbacks,
storyContextMenuCallbacks: ContactSearchAdapter.StoryContextMenuCallbacks storyContextMenuCallbacks: ContactSearchAdapter.StoryContextMenuCallbacks
): PagingMappingAdapter<ContactSearchKey> { ): PagingMappingAdapter<ContactSearchKey> {
return ContactSearchAdapter(displayCheckBox, displaySmsTag, callbacks, storyContextMenuCallbacks) return ContactSearchAdapter(context, fixedContacts, displayCheckBox, displaySmsTag, displayPhoneNumber, callbacks, longClickCallbacks, storyContextMenuCallbacks)
} }
} }
} }

View file

@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.contacts.paged
import android.database.Cursor import android.database.Cursor
import org.signal.core.util.requireLong import org.signal.core.util.requireLong
import org.signal.paging.PagedDataSource import org.signal.paging.PagedDataSource
import org.thoughtcrime.securesms.contacts.ContactRepository
import org.thoughtcrime.securesms.contacts.paged.collections.ContactSearchCollection import org.thoughtcrime.securesms.contacts.paged.collections.ContactSearchCollection
import org.thoughtcrime.securesms.contacts.paged.collections.ContactSearchIterator import org.thoughtcrime.securesms.contacts.paged.collections.ContactSearchIterator
import org.thoughtcrime.securesms.contacts.paged.collections.CursorSearchIterator import org.thoughtcrime.securesms.contacts.paged.collections.CursorSearchIterator
@ -12,12 +13,15 @@ import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode
import org.thoughtcrime.securesms.database.model.GroupRecord import org.thoughtcrime.securesms.database.model.GroupRecord
import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.keyvalue.StorySend import org.thoughtcrime.securesms.keyvalue.StorySend
import org.thoughtcrime.securesms.phonenumbers.NumberUtil
import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.search.MessageResult import org.thoughtcrime.securesms.search.MessageResult
import org.thoughtcrime.securesms.search.MessageSearchResult import org.thoughtcrime.securesms.search.MessageSearchResult
import org.thoughtcrime.securesms.search.SearchRepository import org.thoughtcrime.securesms.search.SearchRepository
import org.thoughtcrime.securesms.search.ThreadSearchResult import org.thoughtcrime.securesms.search.ThreadSearchResult
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.UsernameUtil
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
/** /**
@ -116,6 +120,8 @@ class ContactSearchPagedDataSource(
is ContactSearchConfiguration.Section.Messages -> getMessageData(query).getCollectionSize(section, query, null) is ContactSearchConfiguration.Section.Messages -> getMessageData(query).getCollectionSize(section, query, null)
is ContactSearchConfiguration.Section.GroupsWithMembers -> getGroupsWithMembersIterator(query).getCollectionSize(section, query, null) is ContactSearchConfiguration.Section.GroupsWithMembers -> getGroupsWithMembersIterator(query).getCollectionSize(section, query, null)
is ContactSearchConfiguration.Section.ContactsWithoutThreads -> getContactsWithoutThreadsIterator(query).getCollectionSize(section, query, null) is ContactSearchConfiguration.Section.ContactsWithoutThreads -> getContactsWithoutThreadsIterator(query).getCollectionSize(section, query, null)
is ContactSearchConfiguration.Section.PhoneNumber -> if (isPossiblyPhoneNumber(query)) 1 else 0
is ContactSearchConfiguration.Section.Username -> if (isPossiblyUsername(query)) 1 else 0
} }
} }
@ -152,17 +158,68 @@ class ContactSearchPagedDataSource(
is ContactSearchConfiguration.Section.Messages -> getMessageContactData(section, query, startIndex, endIndex) is ContactSearchConfiguration.Section.Messages -> getMessageContactData(section, query, startIndex, endIndex)
is ContactSearchConfiguration.Section.GroupsWithMembers -> getGroupsWithMembersContactData(section, query, startIndex, endIndex) is ContactSearchConfiguration.Section.GroupsWithMembers -> getGroupsWithMembersContactData(section, query, startIndex, endIndex)
is ContactSearchConfiguration.Section.ContactsWithoutThreads -> getContactsWithoutThreadsContactData(section, query, startIndex, endIndex) is ContactSearchConfiguration.Section.ContactsWithoutThreads -> getContactsWithoutThreadsContactData(section, query, startIndex, endIndex)
is ContactSearchConfiguration.Section.PhoneNumber -> getPossiblePhoneNumber(section, query)
is ContactSearchConfiguration.Section.Username -> getPossibleUsername(section, query)
}
}
private fun isPossiblyPhoneNumber(query: String?): Boolean {
if (query == null) {
return false
}
return if (FeatureFlags.usernames()) {
NumberUtil.isVisuallyValidNumberOrEmail(query)
} else {
NumberUtil.isValidSmsOrEmail(query)
}
}
private fun isPossiblyUsername(query: String?): Boolean {
return query != null && FeatureFlags.usernames() && UsernameUtil.isValidUsernameForSearch(query)
}
private fun getPossiblePhoneNumber(section: ContactSearchConfiguration.Section.PhoneNumber, query: String?): List<ContactSearchData> {
return if (isPossiblyPhoneNumber(query)) {
listOf(ContactSearchData.UnknownRecipient(section.sectionKey, section.newRowMode, query!!))
} else {
emptyList()
}
}
private fun getPossibleUsername(section: ContactSearchConfiguration.Section.Username, query: String?): List<ContactSearchData> {
return if (isPossiblyUsername(query)) {
listOf(ContactSearchData.UnknownRecipient(section.sectionKey, section.newRowMode, query!!))
} else {
emptyList()
} }
} }
private fun getNonGroupSearchIterator(section: ContactSearchConfiguration.Section.Individuals, query: String?): ContactSearchIterator<Cursor> { private fun getNonGroupSearchIterator(section: ContactSearchConfiguration.Section.Individuals, query: String?): ContactSearchIterator<Cursor> {
return when (section.transportType) { return when (section.transportType) {
ContactSearchConfiguration.TransportType.PUSH -> CursorSearchIterator(contactSearchPagedDataSourceRepository.querySignalContacts(query, section.includeSelf)) ContactSearchConfiguration.TransportType.PUSH -> CursorSearchIterator(wrapRecipientCursor(contactSearchPagedDataSourceRepository.querySignalContacts(query, section.includeSelf)))
ContactSearchConfiguration.TransportType.SMS -> CursorSearchIterator(contactSearchPagedDataSourceRepository.queryNonSignalContacts(query)) ContactSearchConfiguration.TransportType.SMS -> CursorSearchIterator(wrapRecipientCursor(contactSearchPagedDataSourceRepository.queryNonSignalContacts(query)))
ContactSearchConfiguration.TransportType.ALL -> CursorSearchIterator(contactSearchPagedDataSourceRepository.queryNonGroupContacts(query, section.includeSelf)) ContactSearchConfiguration.TransportType.ALL -> CursorSearchIterator(wrapRecipientCursor(contactSearchPagedDataSourceRepository.queryNonGroupContacts(query, section.includeSelf)))
} }
} }
private fun wrapRecipientCursor(cursor: Cursor?): Cursor? {
return if (cursor == null || cursor.count == 0) {
null
} else {
WrapAroundCursor(cursor, offset = getFirstAlphaRecipientPosition(cursor))
}
}
private fun getFirstAlphaRecipientPosition(cursor: Cursor): Int {
cursor.moveToPosition(-1)
while (cursor.moveToNext()) {
val sortName = cursor.getString(cursor.getColumnIndexOrThrow(ContactRepository.NAME_COLUMN))
if (!sortName.first().isDigit()) {
return cursor.position
}
}
return 0
}
private fun getNonGroupHeaderLetterMap(section: ContactSearchConfiguration.Section.Individuals, query: String?): Map<RecipientId, String> { private fun getNonGroupHeaderLetterMap(section: ContactSearchConfiguration.Section.Individuals, query: String?): Map<RecipientId, String> {
return when (section.transportType) { return when (section.transportType) {
ContactSearchConfiguration.TransportType.PUSH -> contactSearchPagedDataSourceRepository.querySignalContactLetterHeaders(query, section.includeSelf) ContactSearchConfiguration.TransportType.PUSH -> contactSearchPagedDataSourceRepository.querySignalContactLetterHeaders(query, section.includeSelf)

View file

@ -112,6 +112,10 @@ class ContactSearchViewModel(
return selectionStore.state return selectionStore.state
} }
fun clearSelection() {
selectionStore.update { emptySet() }
}
fun addToVisibleGroupStories(groupStories: Set<ContactSearchKey.RecipientSearchKey>) { fun addToVisibleGroupStories(groupStories: Set<ContactSearchKey.RecipientSearchKey>) {
disposables += contactSearchRepository.markDisplayAsStory(groupStories.map { it.recipientId }).subscribe { disposables += contactSearchRepository.markDisplayAsStory(groupStories.map { it.recipientId }).subscribe {
configurationStore.update { state -> configurationStore.update { state ->

View file

@ -0,0 +1,101 @@
package org.thoughtcrime.securesms.contacts.paged
import android.database.Cursor
import android.database.CursorWrapper
import androidx.annotation.IntRange
import java.lang.Integer.max
import java.lang.Integer.min
/**
* Cursor that takes a start offset and will wrap results around to the other side.
*
* For example, given a cursor with rows:
*
* [A, B, C, D, E]
*
* When I create a wrap-around cursor with a start offset = 2, and I read out all results,
* I expect:
*
* [C, D, E, A, B]
*/
class WrapAroundCursor(delegate: Cursor, @IntRange(from = 0) private val offset: Int) : CursorWrapper(delegate) {
init {
check(offset < delegate.count && offset >= 0)
}
override fun moveToPosition(position: Int): Boolean {
return if (offset == 0) {
super.moveToPosition(position)
} else {
if (position == -1 || position == count) {
super.moveToPosition(position)
} else {
super.moveToPosition((position + offset) % count)
}
}
}
override fun moveToFirst(): Boolean {
return super.moveToPosition(offset)
}
override fun moveToLast(): Boolean {
return if (offset == 0) {
super.moveToLast()
} else {
super.moveToPosition(offset - 1)
}
}
override fun move(offset: Int): Boolean {
return if (offset == 0) {
super.move(offset)
} else {
val position = max(min(offset + position, count), -1)
moveToPosition(position)
}
}
override fun moveToNext(): Boolean {
return move(1)
}
override fun moveToPrevious(): Boolean {
return move(-1)
}
override fun isLast(): Boolean {
return if (offset == 0) {
super.isLast()
} else {
return position == count - 1
}
}
override fun isFirst(): Boolean {
return if (offset == 0) {
super.isFirst()
} else {
return position == 0
}
}
override fun getPosition(): Int {
return if (offset == 0) {
super.getPosition()
} else {
val position = super.getPosition()
if (position < 0 || position == count) {
return position
}
val distance = (position - offset) % count
if (distance >= 0) {
distance
} else {
count + distance
}
}
}
}

View file

@ -2,12 +2,12 @@ package org.thoughtcrime.securesms.contacts.selection
import android.os.Bundle import android.os.Bundle
import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode
import org.thoughtcrime.securesms.groups.SelectionLimits import org.thoughtcrime.securesms.groups.SelectionLimits
import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.recipients.RecipientId
data class ContactSelectionArguments( data class ContactSelectionArguments(
val displayMode: Int = ContactsCursorLoader.DisplayMode.FLAG_ALL, val displayMode: Int = ContactSelectionDisplayMode.FLAG_ALL,
val isRefreshable: Boolean = true, val isRefreshable: Boolean = true,
val displayRecents: Boolean = false, val displayRecents: Boolean = false,
val selectionLimits: SelectionLimits? = null, val selectionLimits: SelectionLimits? = null,

View file

@ -120,11 +120,17 @@ class MultiselectForwardFragment :
contactSearchRecycler = view.findViewById(R.id.contact_selection_list) contactSearchRecycler = view.findViewById(R.id.contact_selection_list)
contactSearchMediator = ContactSearchMediator( contactSearchMediator = ContactSearchMediator(
this, this,
emptySet(),
FeatureFlags.shareSelectionLimit(), FeatureFlags.shareSelectionLimit(),
!args.selectSingleRecipient, !args.selectSingleRecipient,
ContactSearchAdapter.DisplaySmsTag.DEFAULT, ContactSearchAdapter.DisplaySmsTag.DEFAULT,
ContactSearchAdapter.DisplayPhoneNumber.NEVER,
this::getConfiguration, this::getConfiguration,
this::filterContacts object : ContactSearchMediator.SimpleCallbacks() {
override fun onBeforeContactsSelected(view: View?, contactSearchKeys: Set<ContactSearchKey>): Set<ContactSearchKey> {
return filterContacts(view, contactSearchKeys)
}
}
) )
contactSearchRecycler.adapter = contactSearchMediator.adapter contactSearchRecycler.adapter = contactSearchMediator.adapter

View file

@ -294,22 +294,32 @@ public class ConversationListFragment extends MainFragment implements ActionMode
cameraFab.setVisibility(View.VISIBLE); cameraFab.setVisibility(View.VISIBLE);
contactSearchMediator = new ContactSearchMediator(this, contactSearchMediator = new ContactSearchMediator(this,
Collections.emptySet(),
SelectionLimits.NO_LIMITS, SelectionLimits.NO_LIMITS,
false, false,
ContactSearchAdapter.DisplaySmsTag.DEFAULT, ContactSearchAdapter.DisplaySmsTag.DEFAULT,
ContactSearchAdapter.DisplayPhoneNumber.NEVER,
this::mapSearchStateToConfiguration, this::mapSearchStateToConfiguration,
(v, s) -> s, new ContactSearchMediator.SimpleCallbacks(),
false, false,
(displayCheckBox, (context,
fixedContacts,
displayCheckBox,
displaySmsTag, displaySmsTag,
displayPhoneNumber,
callbacks, callbacks,
longClickCallbacks,
storyContextMenuCallbacks storyContextMenuCallbacks
) -> { ) -> {
//noinspection CodeBlock2Expr //noinspection CodeBlock2Expr
return new ConversationListSearchAdapter( return new ConversationListSearchAdapter(
context,
fixedContacts,
displayCheckBox, displayCheckBox,
displaySmsTag, displaySmsTag,
displayPhoneNumber,
new ContactSearchClickCallbacks(callbacks), new ContactSearchClickCallbacks(callbacks),
longClickCallbacks,
storyContextMenuCallbacks, storyContextMenuCallbacks,
getViewLifecycleOwner(), getViewLifecycleOwner(),
GlideApp.with(this) GlideApp.with(this)
@ -1894,6 +1904,11 @@ public class ConversationListFragment extends MainFragment implements ActionMode
public void onExpandClicked(@NonNull ContactSearchData.Expand expand) { public void onExpandClicked(@NonNull ContactSearchData.Expand expand) {
delegate.onExpandClicked(expand); delegate.onExpandClicked(expand);
} }
@Override
public void onUnknownRecipientClicked(@NonNull View view, @NonNull ContactSearchData.UnknownRecipient unknownRecipient, boolean isSelected) {
throw new UnsupportedOperationException();
}
} }
public interface Callback extends Material3OnScrollHelperBinder, SearchBinder { public interface Callback extends Material3OnScrollHelperBinder, SearchBinder {

View file

@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.conversationlist package org.thoughtcrime.securesms.conversationlist
import android.content.Context
import android.view.View import android.view.View
import android.widget.TextView import android.widget.TextView
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
@ -9,6 +10,7 @@ import org.thoughtcrime.securesms.contacts.paged.ArbitraryRepository
import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
import org.thoughtcrime.securesms.contacts.paged.ContactSearchData import org.thoughtcrime.securesms.contacts.paged.ContactSearchData
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.conversationlist.model.ConversationSet import org.thoughtcrime.securesms.conversationlist.model.ConversationSet
import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
@ -22,13 +24,17 @@ import java.util.Locale
* as well as ChatFilter row support and empty state handler. * as well as ChatFilter row support and empty state handler.
*/ */
class ConversationListSearchAdapter( class ConversationListSearchAdapter(
context: Context,
fixedContacts: Set<ContactSearchKey>,
displayCheckBox: Boolean, displayCheckBox: Boolean,
displaySmsTag: DisplaySmsTag, displaySmsTag: DisplaySmsTag,
displayPhoneNumber: DisplayPhoneNumber,
onClickedCallbacks: ConversationListSearchClickCallbacks, onClickedCallbacks: ConversationListSearchClickCallbacks,
longClickCallbacks: LongClickCallbacks,
storyContextMenuCallbacks: StoryContextMenuCallbacks, storyContextMenuCallbacks: StoryContextMenuCallbacks,
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
glideRequests: GlideRequests glideRequests: GlideRequests
) : ContactSearchAdapter(displayCheckBox, displaySmsTag, onClickedCallbacks, storyContextMenuCallbacks) { ) : ContactSearchAdapter(context, fixedContacts, displayCheckBox, displaySmsTag, displayPhoneNumber, onClickedCallbacks, longClickCallbacks, storyContextMenuCallbacks) {
init { init {
registerFactory( registerFactory(

View file

@ -4362,7 +4362,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
SELECT 1 SELECT 1
FROM ${GroupTable.MembershipTable.TABLE_NAME} FROM ${GroupTable.MembershipTable.TABLE_NAME}
INNER JOIN ${GroupTable.TABLE_NAME} ON ${GroupTable.TABLE_NAME}.${GroupTable.GROUP_ID} = ${GroupTable.MembershipTable.TABLE_NAME}.${GroupTable.MembershipTable.GROUP_ID} INNER JOIN ${GroupTable.TABLE_NAME} ON ${GroupTable.TABLE_NAME}.${GroupTable.GROUP_ID} = ${GroupTable.MembershipTable.TABLE_NAME}.${GroupTable.MembershipTable.GROUP_ID}
WHERE ${GroupTable.MembershipTable.TABLE_NAME}.${GroupTable.MembershipTable.RECIPIENT_ID} = ${TABLE_NAME}.$ID AND ${GroupTable.TABLE_NAME}.${GroupTable.ACTIVE} = 1 AND ${GroupTable.TABLE_NAME}.${GroupTable.MMS} = 0 WHERE ${GroupTable.MembershipTable.TABLE_NAME}.${GroupTable.MembershipTable.RECIPIENT_ID} = $TABLE_NAME.$ID AND ${GroupTable.TABLE_NAME}.${GroupTable.ACTIVE} = 1 AND ${GroupTable.TABLE_NAME}.${GroupTable.MMS} = 0
) )
""".toSingleLine() """.toSingleLine()
const val FILTER_GROUPS = " AND $GROUP_ID IS NULL" const val FILTER_GROUPS = " AND $GROUP_ID IS NULL"

View file

@ -16,7 +16,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.thoughtcrime.securesms.ContactSelectionActivity; import org.thoughtcrime.securesms.ContactSelectionActivity;
import org.thoughtcrime.securesms.ContactSelectionListFragment; import org.thoughtcrime.securesms.ContactSelectionListFragment;
import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader; import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
import org.thoughtcrime.securesms.groups.ui.addtogroup.AddToGroupViewModel.Event; import org.thoughtcrime.securesms.groups.ui.addtogroup.AddToGroupViewModel.Event;
import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.RecipientId;
@ -50,7 +50,7 @@ public final class AddToGroupsActivity extends ContactSelectionActivity {
intent.putExtra(ContactSelectionActivity.EXTRA_LAYOUT_RES_ID, R.layout.add_to_group_activity); intent.putExtra(ContactSelectionActivity.EXTRA_LAYOUT_RES_ID, R.layout.add_to_group_activity);
intent.putExtra(EXTRA_RECIPIENT_ID, recipientId); intent.putExtra(EXTRA_RECIPIENT_ID, recipientId);
intent.putExtra(ContactSelectionListFragment.DISPLAY_MODE, ContactsCursorLoader.DisplayMode.FLAG_ACTIVE_GROUPS); intent.putExtra(ContactSelectionListFragment.DISPLAY_MODE, ContactSelectionDisplayMode.FLAG_ACTIVE_GROUPS);
intent.putParcelableArrayListExtra(ContactSelectionListFragment.CURRENT_SELECTION, new ArrayList<>(currentGroupsMemberOf)); intent.putParcelableArrayListExtra(ContactSelectionListFragment.CURRENT_SELECTION, new ArrayList<>(currentGroupsMemberOf));

View file

@ -17,7 +17,7 @@ import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.ContactSelectionActivity; import org.thoughtcrime.securesms.ContactSelectionActivity;
import org.thoughtcrime.securesms.ContactSelectionListFragment; import org.thoughtcrime.securesms.ContactSelectionListFragment;
import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader; import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery; import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
import org.thoughtcrime.securesms.database.RecipientTable; import org.thoughtcrime.securesms.database.RecipientTable;
import org.thoughtcrime.securesms.groups.ui.creategroup.details.AddGroupDetailsActivity; import org.thoughtcrime.securesms.groups.ui.creategroup.details.AddGroupDetailsActivity;
@ -51,8 +51,8 @@ public class CreateGroupActivity extends ContactSelectionActivity {
intent.putExtra(ContactSelectionActivity.EXTRA_LAYOUT_RES_ID, R.layout.create_group_activity); intent.putExtra(ContactSelectionActivity.EXTRA_LAYOUT_RES_ID, R.layout.create_group_activity);
boolean smsEnabled = SignalStore.misc().getSmsExportPhase().allowSmsFeatures(); boolean smsEnabled = SignalStore.misc().getSmsExportPhase().allowSmsFeatures();
int displayMode = smsEnabled ? ContactsCursorLoader.DisplayMode.FLAG_SMS | ContactsCursorLoader.DisplayMode.FLAG_PUSH int displayMode = smsEnabled ? ContactSelectionDisplayMode.FLAG_SMS | ContactSelectionDisplayMode.FLAG_PUSH
: ContactsCursorLoader.DisplayMode.FLAG_PUSH; : ContactSelectionDisplayMode.FLAG_PUSH;
intent.putExtra(ContactSelectionListFragment.DISPLAY_MODE, displayMode); intent.putExtra(ContactSelectionListFragment.DISPLAY_MODE, displayMode);
intent.putExtra(ContactSelectionListFragment.SELECTION_LIMITS, FeatureFlags.groupLimits().excludingSelf()); intent.putExtra(ContactSelectionListFragment.SELECTION_LIMITS, FeatureFlags.groupLimits().excludingSelf());

View file

@ -67,6 +67,7 @@ class ChooseGroupStoryBottomSheet : FixedRoundedCornerBottomSheetDialogFragment(
selectionLimits = FeatureFlags.shareSelectionLimit(), selectionLimits = FeatureFlags.shareSelectionLimit(),
displayCheckBox = true, displayCheckBox = true,
displaySmsTag = ContactSearchAdapter.DisplaySmsTag.DEFAULT, displaySmsTag = ContactSearchAdapter.DisplaySmsTag.DEFAULT,
displayPhoneNumber = ContactSearchAdapter.DisplayPhoneNumber.NEVER,
mapStateToConfiguration = { state -> mapStateToConfiguration = { state ->
ContactSearchConfiguration.build { ContactSearchConfiguration.build {
query = state.query query = state.query

View file

@ -15,7 +15,7 @@ import org.thoughtcrime.securesms.ContactSelectionListFragment;
import org.thoughtcrime.securesms.LoggingFragment; import org.thoughtcrime.securesms.LoggingFragment;
import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.ContactFilterView; import org.thoughtcrime.securesms.components.ContactFilterView;
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode; import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
import org.thoughtcrime.securesms.conversation.ConversationIntents; import org.thoughtcrime.securesms.conversation.ConversationIntents;
import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.payments.CanNotSendPaymentDialog; import org.thoughtcrime.securesms.payments.CanNotSendPaymentDialog;
@ -49,7 +49,7 @@ public class PaymentRecipientSelectionFragment extends LoggingFragment implement
Bundle arguments = new Bundle(); Bundle arguments = new Bundle();
arguments.putBoolean(ContactSelectionListFragment.REFRESHABLE, false); arguments.putBoolean(ContactSelectionListFragment.REFRESHABLE, false);
arguments.putInt(ContactSelectionListFragment.DISPLAY_MODE, DisplayMode.FLAG_PUSH | DisplayMode.FLAG_HIDE_NEW); arguments.putInt(ContactSelectionListFragment.DISPLAY_MODE, ContactSelectionDisplayMode.FLAG_PUSH | ContactSelectionDisplayMode.FLAG_HIDE_NEW);
arguments.putBoolean(ContactSelectionListFragment.CAN_SELECT_SELF, false); arguments.putBoolean(ContactSelectionListFragment.CAN_SELECT_SELF, false);
Fragment child = getChildFragmentManager().findFragmentById(R.id.contact_selection_list_fragment_holder); Fragment child = getChildFragmentManager().findFragmentById(R.id.contact_selection_list_fragment_holder);

View file

@ -29,6 +29,7 @@ class ViewAllSignalConnectionsFragment : Fragment(R.layout.view_all_signal_conne
selectionLimits = SelectionLimits(0, 0), selectionLimits = SelectionLimits(0, 0),
displayCheckBox = false, displayCheckBox = false,
displaySmsTag = ContactSearchAdapter.DisplaySmsTag.IF_NOT_REGISTERED, displaySmsTag = ContactSearchAdapter.DisplaySmsTag.IF_NOT_REGISTERED,
displayPhoneNumber = ContactSearchAdapter.DisplayPhoneNumber.NEVER,
mapStateToConfiguration = { getConfiguration() }, mapStateToConfiguration = { getConfiguration() },
performSafetyNumberChecks = false performSafetyNumberChecks = false
) )

View file

@ -12,13 +12,12 @@ import com.google.android.material.button.MaterialButton
import org.signal.core.util.dp import org.signal.core.util.dp
import org.thoughtcrime.securesms.ContactSelectionListFragment import org.thoughtcrime.securesms.ContactSelectionListFragment
import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode
import org.thoughtcrime.securesms.contacts.HeaderAction import org.thoughtcrime.securesms.contacts.HeaderAction
import org.thoughtcrime.securesms.contacts.selection.ContactSelectionArguments import org.thoughtcrime.securesms.contacts.selection.ContactSelectionArguments
import org.thoughtcrime.securesms.database.model.DistributionListId import org.thoughtcrime.securesms.database.model.DistributionListId
import org.thoughtcrime.securesms.groups.SelectionLimits import org.thoughtcrime.securesms.groups.SelectionLimits
import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sharing.ShareContact
import org.thoughtcrime.securesms.util.LifecycleDisposable import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.fragments.findListener import org.thoughtcrime.securesms.util.fragments.findListener
import java.util.Optional import java.util.Optional
@ -78,7 +77,7 @@ abstract class BaseStoryRecipientSelectionFragment : Fragment(R.layout.stories_b
viewModel.state.observe(viewLifecycleOwner) { viewModel.state.observe(viewLifecycleOwner) {
if (it.distributionListId == null || it.privateStory != null) { if (it.distributionListId == null || it.privateStory != null) {
getAttachedContactSelectionFragment().markSelected(it.selection.map(::ShareContact).toSet()) getAttachedContactSelectionFragment().markSelected(it.selection.toSet())
presentTitle(toolbar, it.selection.size) presentTitle(toolbar, it.selection.size)
} }
} }
@ -141,7 +140,7 @@ abstract class BaseStoryRecipientSelectionFragment : Fragment(R.layout.stories_b
private fun initializeContactSelectionFragment() { private fun initializeContactSelectionFragment() {
val contactSelectionListFragment = ContactSelectionListFragment() val contactSelectionListFragment = ContactSelectionListFragment()
val arguments = ContactSelectionArguments( val arguments = ContactSelectionArguments(
displayMode = ContactsCursorLoader.DisplayMode.FLAG_PUSH or ContactsCursorLoader.DisplayMode.FLAG_HIDE_NEW, displayMode = ContactSelectionDisplayMode.FLAG_PUSH or ContactSelectionDisplayMode.FLAG_HIDE_NEW,
isRefreshable = false, isRefreshable = false,
displayRecents = false, displayRecents = false,
selectionLimits = SelectionLimits.NO_LIMITS, selectionLimits = SelectionLimits.NO_LIMITS,

View file

@ -80,6 +80,6 @@
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/blocked_users_header" app:layout_constraintTop_toBottomOf="@id/blocked_users_header"
tools:listitem="@layout/contact_selection_list_item" /> tools:listitem="@layout/contact_search_item" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,46 +1,35 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<org.thoughtcrime.securesms.contacts.ContactSelectionListItem xmlns:android="http://schemas.android.com/apk/res/android" <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
tools:viewBindingIgnore="true"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="@drawable/rounded_inset_ripple_background" android:background="@drawable/conversation_item_background"
android:focusable="true" android:focusable="true"
android:minHeight="@dimen/contact_selection_item_height" android:minHeight="@dimen/contact_selection_item_height"
android:paddingStart="@dimen/dsl_settings_gutter" android:paddingStart="@dimen/dsl_settings_gutter"
android:paddingEnd="@dimen/dsl_settings_gutter"> android:paddingEnd="@dimen/dsl_settings_gutter"
tools:viewBindingIgnore="true">
<org.thoughtcrime.securesms.components.AvatarImageView <com.google.android.material.imageview.ShapeableImageView
android:id="@+id/contact_photo_image" android:id="@+id/contact_photo_image"
android:layout_width="40dp" android:layout_width="40dp"
android:layout_height="40dp" android:layout_height="40dp"
android:contentDescription="@string/SingleContactSelectionActivity_contact_photo" android:background="@color/signal_colorSurfaceVariant"
android:cropToPadding="true" android:importantForAccessibility="no"
android:foreground="@drawable/contact_photo_background" app:contentPadding="8dp"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
tools:ignore="UnusedAttribute" app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Signal.Circle"
tools:src="@color/blue_600" /> app:srcCompat="@drawable/ic_search_24"
tools:ignore="UnusedAttribute" />
<org.thoughtcrime.securesms.badges.BadgeImageView
android:id="@+id/contact_badge"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginStart="20dp"
android:layout_marginTop="22dp"
android:contentDescription="@string/ImageView__badge"
app:badge_size="medium"
app:layout_constraintStart_toStartOf="@id/contact_photo_image"
app:layout_constraintTop_toTopOf="@id/contact_photo_image"
tools:visibility="visible" />
<androidx.appcompat.widget.AppCompatCheckBox <androidx.appcompat.widget.AppCompatCheckBox
android:id="@+id/check_box" android:id="@+id/check_box"
android:layout_width="24dp" android:layout_width="24dp"
android:layout_height="24dp" android:layout_height="24dp"
android:background="@drawable/contact_selection_checkbox" android:background="?contactCheckboxBackground"
android:button="@null" android:button="@null"
android:clickable="false" android:clickable="false"
android:focusable="false" android:focusable="false"
@ -58,10 +47,10 @@
android:drawablePadding="4dp" android:drawablePadding="4dp"
android:ellipsize="marquee" android:ellipsize="marquee"
android:singleLine="true" android:singleLine="true"
android:textAppearance="@style/Signal.Text.BodyLarge" android:textAppearance="@style/TextAppearance.Signal.Body1"
android:textColor="@color/signal_text_primary" android:textColor="@color/signal_text_primary"
app:layout_constraintBottom_toTopOf="@id/number" app:layout_constraintBottom_toTopOf="@id/number"
app:layout_constraintEnd_toStartOf="@id/sms_tag" app:layout_constraintEnd_toStartOf="@id/check_box"
app:layout_constraintStart_toEndOf="@id/contact_photo_image" app:layout_constraintStart_toEndOf="@id/contact_photo_image"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed" app:layout_constraintVertical_chainStyle="packed"
@ -76,47 +65,17 @@
android:layout_marginStart="16dp" android:layout_marginStart="16dp"
android:ellipsize="marquee" android:ellipsize="marquee"
android:singleLine="true" android:singleLine="true"
android:textAppearance="@style/Signal.Text.BodyMedium" android:textAppearance="@style/TextAppearance.Signal.Body2"
android:textColor="@color/signal_text_secondary" android:textColor="@color/signal_text_secondary"
android:textDirection="ltr" android:textDirection="ltr"
app:emoji_forceCustom="true" app:emoji_forceCustom="true"
app:layout_constrainedWidth="true" app:layout_constrainedWidth="true"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/label" app:layout_constraintEnd_toStartOf="@id/check_box"
app:layout_constraintHorizontal_bias="0" app:layout_constraintHorizontal_bias="0"
app:layout_constraintHorizontal_chainStyle="packed" app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toEndOf="@id/contact_photo_image" app:layout_constraintStart_toEndOf="@id/contact_photo_image"
app:layout_constraintTop_toBottomOf="@id/name" app:layout_constraintTop_toBottomOf="@id/name"
tools:text="@sample/contacts.json/data/number" /> tools:text="@sample/contacts.json/data/number" />
<TextView </androidx.constraintlayout.widget.ConstraintLayout>
android:id="@+id/label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:paddingStart="4dp"
android:singleLine="true"
android:textAppearance="@style/Signal.Text.BodyMedium"
android:textColor="@color/signal_text_secondary"
app:layout_constraintEnd_toEndOf="@id/name"
app:layout_constraintStart_toEndOf="@id/number"
app:layout_constraintTop_toTopOf="@id/number"
tools:ignore="RtlSymmetry"
tools:text="· Mobile" />
<TextView
android:id="@+id/sms_tag"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_marginEnd="20dp"
android:text="@string/ContactSelectionListItem__sms"
android:textAppearance="@style/Signal.Text.BodyMedium"
android:textColor="@color/signal_text_secondary"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/check_box"
app:layout_constraintTop_toTopOf="parent"
app:layout_goneMarginEnd="0dp"
tools:visibility="visible" />
</org.thoughtcrime.securesms.contacts.ContactSelectionListItem>

View file

@ -24,7 +24,7 @@
android:clipToPadding="false" android:clipToPadding="false"
android:scrollbarThumbVertical="@drawable/contact_selection_scrollbar_thumb" android:scrollbarThumbVertical="@drawable/contact_selection_scrollbar_thumb"
android:scrollbars="vertical" android:scrollbars="vertical"
tools:listitem="@layout/contact_selection_list_item" /> tools:listitem="@layout/contact_search_item" />
<TextView <TextView
android:id="@android:id/empty" android:id="@android:id/empty"

View file

@ -30,12 +30,13 @@ class ContactSearchPagedDataSourceTest {
@Before @Before
fun setUp() { fun setUp() {
whenever(repository.getRecipientFromGroupRecord(any())).thenReturn(Recipient.UNKNOWN) whenever(repository.getRecipientFromGroupRecord(any())).thenReturn(Recipient.UNKNOWN)
whenever(repository.getRecipientFromSearchCursor(cursor)).thenReturn(Recipient.UNKNOWN) whenever(repository.getRecipientFromSearchCursor(any())).thenReturn(Recipient.UNKNOWN)
whenever(repository.getRecipientFromThreadCursor(cursor)).thenReturn(Recipient.UNKNOWN) whenever(repository.getRecipientFromThreadCursor(cursor)).thenReturn(Recipient.UNKNOWN)
whenever(repository.getRecipientFromDistributionListCursor(cursor)).thenReturn(Recipient.UNKNOWN) whenever(repository.getRecipientFromDistributionListCursor(cursor)).thenReturn(Recipient.UNKNOWN)
whenever(repository.getPrivacyModeFromDistributionListCursor(cursor)).thenReturn(DistributionListPrivacyMode.ALL) whenever(repository.getPrivacyModeFromDistributionListCursor(cursor)).thenReturn(DistributionListPrivacyMode.ALL)
whenever(repository.getGroupStories()).thenReturn(emptySet()) whenever(repository.getGroupStories()).thenReturn(emptySet())
whenever(repository.getLatestStorySends(any())).thenReturn(emptyList()) whenever(repository.getLatestStorySends(any())).thenReturn(emptyList())
whenever(cursor.getString(any())).thenReturn("A")
whenever(cursor.moveToPosition(any())).thenCallRealMethod() whenever(cursor.moveToPosition(any())).thenCallRealMethod()
whenever(cursor.moveToNext()).thenCallRealMethod() whenever(cursor.moveToNext()).thenCallRealMethod()
whenever(cursor.position).thenCallRealMethod() whenever(cursor.position).thenCallRealMethod()

View file

@ -0,0 +1,241 @@
package org.thoughtcrime.securesms.contacts.paged
import android.app.Application
import android.database.MatrixCursor
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.signal.core.util.readToList
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class)
class WrapAroundCursorTest {
private val values = listOf("Cereal", "Marshmallows", "Toast", "Meatballs", "Oatmeal")
private val delegate = MatrixCursor(arrayOf("Breakfast")).apply {
values.forEach {
addRow(arrayOf(it))
}
}
@Test
fun `Given an offset of zero, then I expect the original order`() {
val testSubject = WrapAroundCursor(delegate, offset = 0)
val actual = testSubject.readToList { cursor -> cursor.getString(0) }
assertEquals(values, actual)
}
@Test
fun `Given an offset of two, then I expect wrap around`() {
val testSubject = WrapAroundCursor(delegate, offset = 2)
val expected = values.drop(2) + values.dropLast(3)
val actual = testSubject.readToList { cursor -> cursor.getString(0) }
assertEquals(expected, actual)
}
@Test
fun `Given an offset of two and internal position of 1, when I getPosition, then I expect an offset position`() {
val testSubject = WrapAroundCursor(delegate, offset = 2)
delegate.moveToPosition(1)
val actual = testSubject.position
assertEquals(4, actual)
}
@Test
fun `Given an offset of two and internal position of 4, when I getPosition, then I expect an offset position`() {
val testSubject = WrapAroundCursor(delegate, offset = 2)
delegate.moveToPosition(4)
val actual = testSubject.position
assertEquals(2, actual)
}
@Test
fun `Given an offset of two and internal position of 2, when I getPosition, then I expect an offset position`() {
val testSubject = WrapAroundCursor(delegate, offset = 2)
delegate.moveToPosition(2)
val actual = testSubject.position
assertEquals(0, actual)
}
@Test
fun `Given an offset of two and internal position of -1, when I getPosition, then I expect -1`() {
val testSubject = WrapAroundCursor(delegate, offset = 2)
delegate.moveToPosition(-1)
val actual = testSubject.position
assertEquals(-1, actual)
}
@Test
fun `Given an offset of two and internal position of 5, when I getPosition, then I expect 5`() {
val testSubject = WrapAroundCursor(delegate, offset = 2)
delegate.moveToPosition(5)
val actual = testSubject.position
assertEquals(5, actual)
}
@Test
fun `Given an offset of two, when I set internal cursor position to 2 and isFirst, then I expect true`() {
val testSubject = WrapAroundCursor(delegate, offset = 2)
delegate.moveToPosition(2)
val actual = testSubject.isFirst
assertTrue(actual)
}
@Test
fun `Given an offset of two, when I set internal cursor position to 0 and isFirst, then I expect false`() {
val testSubject = WrapAroundCursor(delegate, offset = 2)
delegate.moveToPosition(0)
val actual = testSubject.isFirst
assertFalse(actual)
}
@Test
fun `Given an offset of two, when I set internal cursor position to 1 and isLast, then I expect true`() {
val testSubject = WrapAroundCursor(delegate, offset = 2)
delegate.moveToPosition(1)
val actual = testSubject.isLast
assertTrue(actual)
}
@Test
fun `Given an offset of two, when I set internal cursor position to 4 and isLast, then I expect false`() {
val testSubject = WrapAroundCursor(delegate, offset = 2)
delegate.moveToPosition(4)
val actual = testSubject.isLast
assertFalse(actual)
}
@Test
fun `Given an offset of two, when I moveToPosition to 0, then I expect the internal cursor to be at position 2`() {
val testSubject = WrapAroundCursor(delegate, offset = 2)
testSubject.moveToPosition(0)
val actual = delegate.position
assertEquals(2, actual)
}
@Test
fun `Given an offset of two, when I moveToPosition to 4, then I expect the internal cursor to be at position 1`() {
val testSubject = WrapAroundCursor(delegate, offset = 2)
testSubject.moveToPosition(4)
val actual = delegate.position
assertEquals(1, actual)
}
@Test
fun `Given an offset of two, when I moveToPosition to -1, then I expect the internal cursor to be at position -1`() {
val testSubject = WrapAroundCursor(delegate, offset = 2)
testSubject.moveToPosition(-1)
val actual = delegate.position
assertEquals(-1, actual)
}
@Test
fun `Given an offset of two, when I moveToPosition to 5, then I expect the internal cursor to be at position 5`() {
val testSubject = WrapAroundCursor(delegate, offset = 2)
testSubject.moveToPosition(5)
val actual = delegate.position
assertEquals(5, actual)
}
@Test
fun `Given an offset of two, when I moveToFirst, then I expect the internal cursor to be at position 2`() {
val testSubject = WrapAroundCursor(delegate, offset = 2)
testSubject.moveToFirst()
val actual = delegate.position
assertEquals(2, actual)
}
@Test
fun `Given an offset of two, when I moveToLast, then I expect the internal cursor to be at position 1`() {
val testSubject = WrapAroundCursor(delegate, offset = 2)
testSubject.moveToLast()
val actual = delegate.position
assertEquals(1, actual)
}
@Test
fun `Given an offset of two and at first position, when I move 4, then I expect the internal cursor to be at position 1`() {
val testSubject = WrapAroundCursor(delegate, offset = 2)
testSubject.moveToFirst()
testSubject.move(4)
val actual = delegate.position
assertEquals(1, actual)
}
@Test
fun `Given an offset of two and at first position, when I move 6, then I expect the internal cursor to be at position 5`() {
val testSubject = WrapAroundCursor(delegate, offset = 2)
testSubject.moveToFirst()
testSubject.move(6)
val actual = delegate.position
assertEquals(5, actual)
}
@Test
fun `Given an offset of two and at first position, when I move -1, then I expect the internal cursor to be at position -1`() {
val testSubject = WrapAroundCursor(delegate, offset = 2)
testSubject.moveToFirst()
testSubject.move(-1)
val actual = delegate.position
assertEquals(-1, actual)
}
@Test
fun `Given an offset of two and at last position, when I move -1, then I expect the internal cursor to be at position 0`() {
val testSubject = WrapAroundCursor(delegate, offset = 2)
testSubject.moveToLast()
testSubject.move(-1)
val actual = delegate.position
assertEquals(0, actual)
}
@Test
fun `Given an offset of two and at last position, when I move 1, then I expect the internal cursor to be at position 5`() {
val testSubject = WrapAroundCursor(delegate, offset = 2)
testSubject.moveToLast()
testSubject.move(1)
val actual = delegate.position
assertEquals(5, actual)
}
@Test
fun `Given an offset of two and at last position, when I moveToNext, then I expect the internal cursor to be at position 5`() {
val testSubject = WrapAroundCursor(delegate, offset = 2)
testSubject.moveToLast()
testSubject.moveToNext()
val actual = delegate.position
assertEquals(5, actual)
}
@Test
fun `Given an offset of two and at first position, when I moveToPrevious, then I expect the internal cursor to be at position -1`() {
val testSubject = WrapAroundCursor(delegate, offset = 2)
testSubject.moveToFirst()
testSubject.moveToPrevious()
val actual = delegate.position
assertEquals(-1, actual)
}
@Test
fun `Given an offset of two and at internal position 4, when I moveToNext, then I expect the internal cursor to be at position 0`() {
val testSubject = WrapAroundCursor(delegate, offset = 2)
delegate.moveToPosition(4)
testSubject.moveToNext()
val actual = delegate.position
assertEquals(0, actual)
}
@Test
fun `Given an offset of two and at internal position 0, when I moveToPrevious, then I expect the internal cursor to be at position 4`() {
val testSubject = WrapAroundCursor(delegate, offset = 2)
delegate.moveToPosition(0)
testSubject.moveToPrevious()
val actual = delegate.position
assertEquals(4, actual)
}
}