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.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.keyvalue.SignalStore;
import org.thoughtcrime.securesms.recipients.RecipientId;
@ -71,7 +71,7 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
protected void onCreate(Bundle icicle, boolean ready) {
if (!getIntent().hasExtra(ContactSelectionListFragment.DISPLAY_MODE)) {
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);
}

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.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.graphics.Rect;
import android.os.AsyncTask;
import android.os.Bundle;
@ -40,10 +39,7 @@ import androidx.annotation.Px;
import androidx.appcompat.app.AlertDialog;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.constraintlayout.widget.ConstraintSet;
import androidx.fragment.app.FragmentActivity;
import androidx.lifecycle.ViewModelProvider;
import androidx.loader.app.LoaderManager;
import androidx.loader.content.Loader;
import androidx.recyclerview.widget.DefaultItemAnimator;
import androidx.recyclerview.widget.LinearLayoutManager;
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.logging.Log;
import org.thoughtcrime.securesms.components.RecyclerViewFastScroller;
import org.thoughtcrime.securesms.contacts.AbstractContactsCursorLoader;
import org.thoughtcrime.securesms.contacts.ContactChipViewModel;
import org.thoughtcrime.securesms.contacts.ContactSelectionListAdapter;
import org.thoughtcrime.securesms.contacts.ContactSelectionListItem;
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader;
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode;
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
import org.thoughtcrime.securesms.contacts.HeaderAction;
import org.thoughtcrime.securesms.contacts.LetterHeaderDecoration;
import org.thoughtcrime.securesms.contacts.SelectedContact;
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.groups.SelectionLimits;
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.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.sharing.ShareContact;
import org.thoughtcrime.securesms.util.LifecycleDisposable;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.UsernameUtil;
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.MappingModelList;
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
@ -105,7 +98,6 @@ import kotlin.Unit;
* @author Moxie Marlinspike
*/
public final class ContactSelectionListFragment extends LoggingFragment
implements LoaderManager.LoaderCallbacks<Cursor>
{
@SuppressWarnings("unused")
private static final String TAG = Log.tag(ContactSelectionListFragment.class);
@ -137,27 +129,23 @@ public final class ContactSelectionListFragment extends LoggingFragment
private String cursorFilter;
private RecyclerView recyclerView;
private RecyclerViewFastScroller fastScroller;
private ContactSelectionListAdapter cursorRecyclerViewAdapter;
private RecyclerView chipRecycler;
private OnSelectionLimitReachedListener onSelectionLimitReachedListener;
private AbstractContactsCursorLoaderFactoryProvider cursorFactoryProvider;
private MappingAdapter contactChipAdapter;
private ContactChipViewModel contactChipViewModel;
private LifecycleDisposable lifecycleDisposable;
private HeaderActionProvider headerActionProvider;
private TextView headerActionView;
private ContactSearchMediator contactSearchMediator;
@Nullable private FixedViewsAdapter headerAdapter;
@Nullable private FixedViewsAdapter footerAdapter;
@Nullable private ListCallback listCallback;
@Nullable private ScrollCallback scrollCallback;
@Nullable private OnItemLongClickListener onItemLongClickListener;
private GlideRequests glideRequests;
private SelectionLimits selectionLimit = SelectionLimits.NO_LIMITS;
private Set<RecipientId> currentSelection;
private boolean isMulti;
private boolean hideCount;
private boolean canSelectSelf;
private ListClickListener listClickListener = new ListClickListener();
@Override
public void onAttach(@NonNull Context context) {
@ -191,14 +179,6 @@ public final class ContactSelectionListFragment extends LoggingFragment
onSelectionLimitReachedListener = (OnSelectionLimitReachedListener) getParentFragment();
}
if (context instanceof AbstractContactsCursorLoaderFactoryProvider) {
cursorFactoryProvider = (AbstractContactsCursorLoaderFactoryProvider) context;
}
if (getParentFragment() instanceof AbstractContactsCursorLoaderFactoryProvider) {
cursorFactoryProvider = (AbstractContactsCursorLoaderFactoryProvider) getParentFragment();
}
if (context instanceof HeaderActionProvider) {
headerActionProvider = (HeaderActionProvider) context;
}
@ -234,16 +214,14 @@ public final class ContactSelectionListFragment extends LoggingFragment
if (!TextSecurePreferences.hasSuccessfullyRetrievedDirectory(getActivity())) {
handleContactPermissionGranted();
} else {
LoaderManager.getInstance(this).initLoader(0, null, this);
contactSearchMediator.refresh();
}
})
.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, activity.getIntent().getBooleanExtra(RECENTS, false))) {
LoaderManager.getInstance(this).initLoader(0, null, ContactSelectionListFragment.this);
if (safeArguments().getBoolean(RECENTS, requireActivity().getIntent().getBooleanExtra(RECENTS, false))) {
contactSearchMediator.refresh();
} else {
initializeNoContactsPermission();
}
@ -305,7 +283,6 @@ public final class ContactSelectionListFragment extends LoggingFragment
swipeRefresh.setNestedScrollingEnabled(isRefreshable);
swipeRefresh.setEnabled(isRefreshable);
hideCount = arguments.getBoolean(HIDE_COUNT, intent.getBooleanExtra(HIDE_COUNT, false));
selectionLimit = arguments.getParcelable(SELECTION_LIMITS);
if (selectionLimit == null) {
selectionLimit = intent.getParcelableExtra(SELECTION_LIMITS);
@ -353,6 +330,65 @@ public final class ContactSelectionListFragment extends LoggingFragment
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;
}
@ -372,27 +408,30 @@ public final class ContactSelectionListFragment extends LoggingFragment
}
public @NonNull List<SelectedContact> getSelectedContacts() {
if (cursorRecyclerViewAdapter == null) {
if (contactSearchMediator == null) {
return Collections.emptyList();
}
return cursorRecyclerViewAdapter.getSelectedContacts();
return contactSearchMediator.getSelectedContacts()
.stream()
.map(ContactSearchKey::requireSelectedContact)
.collect(java.util.stream.Collectors.toList());
}
public int getSelectedContactsCount() {
if (cursorRecyclerViewAdapter == null) {
if (contactSearchMediator == null) {
return 0;
}
return cursorRecyclerViewAdapter.getSelectedContactsCount();
return contactSearchMediator.getSelectedContacts().size();
}
public int getTotalMemberCount() {
if (cursorRecyclerViewAdapter == null) {
if (contactSearchMediator == null) {
return 0;
}
return cursorRecyclerViewAdapter.getSelectedContactsCount() + cursorRecyclerViewAdapter.getCurrentContactsCount();
return getSelectedContactsCount() + contactSearchMediator.getFixedContactsSize();
}
private Set<RecipientId> getCurrentSelection() {
@ -410,34 +449,8 @@ public final class ContactSelectionListFragment extends LoggingFragment
}
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.setAdapter(concatenateAdapter);
recyclerView.setAdapter(contactSearchMediator.getAdapter());
recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
@ -458,20 +471,6 @@ public final class ContactSelectionListFragment extends LoggingFragment
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() {
swipeRefresh.setVisibility(View.GONE);
@ -496,7 +495,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
public void setQueryFilter(String filter) {
this.cursorFilter = filter;
LoaderManager.getInstance(this).restartLoader(0, null, this);
contactSearchMediator.onFilterChanged(filter);
}
public void resetQueryFilter() {
@ -513,51 +512,21 @@ public final class ContactSelectionListFragment extends LoggingFragment
}
public void reset() {
cursorRecyclerViewAdapter.clearSelectedContacts();
if (!isDetached() && !isRemoving() && getActivity() != null && !getActivity().isFinishing()) {
LoaderManager.getInstance(this).restartLoader(0, null, this);
}
contactSearchMediator.clearSelection();
fastScroller.setVisibility(View.GONE);
headerActionView.setVisibility(View.GONE);
}
public void setRecyclerViewPaddingBottom(@Px int paddingBottom) {
ViewUtil.setPaddingBottom(recyclerView, paddingBottom);
}
@Override
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) {
private void onLoadFinished(int count) {
swipeRefresh.setVisibility(View.VISIBLE);
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);
boolean useFastScroller = data != null && data.getCount() > 20;
boolean useFastScroller = count > 20;
recyclerView.setVerticalScrollBarEnabled(!useFastScroller);
if (useFastScroller) {
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() {
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.
*/
public void markSelected(@NonNull Set<ShareContact> contacts) {
public void markSelected(@NonNull Set<RecipientId> contacts) {
if (contacts.isEmpty()) {
return;
}
Set<SelectedContact> toMarkSelected = contacts.stream()
.map(contact -> {
if (contact.getRecipientId().isPresent()) {
return SelectedContact.forRecipientId(contact.getRecipientId().get());
} else {
return SelectedContact.forPhone(null, contact.getNumber());
}
})
.filter(c -> !cursorRecyclerViewAdapter.isSelectedContact(c))
.filter(r -> !contactSearchMediator.getSelectedContacts()
.contains(new ContactSearchKey.RecipientSearchKey(r, false)))
.map(SelectedContact::forRecipientId)
.collect(java.util.stream.Collectors.toSet());
if (toMarkSelected.isEmpty()) {
@ -657,22 +614,18 @@ public final class ContactSelectionListFragment extends LoggingFragment
for (final SelectedContact selectedContact : toMarkSelected) {
markContactSelected(selectedContact);
}
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount());
}
private class ListClickListener implements ContactSelectionListAdapter.ItemClickListener {
@Override
public void onItemClick(ContactSelectionListItem contact) {
SelectedContact selectedContact = contact.isUsernameType() ? SelectedContact.forUsername(contact.getRecipientId().orElse(null), contact.getNumber())
: SelectedContact.forPhone(contact.getRecipientId().orElse(null), contact.getNumber());
private class ListClickListener {
public void onItemClick(ContactSearchKey contact) {
SelectedContact selectedContact = contact.requireSelectedContact();
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();
return;
}
if (!isMulti || !cursorRecyclerViewAdapter.isSelectedContact(selectedContact)) {
if (!isMulti || !contactSearchMediator.getSelectedContacts().contains(selectedContact.toContactSearchKey())) {
if (selectionHardLimitReached()) {
if (onSelectionLimitReachedListener != null) {
onSelectionLimitReachedListener.onHardLimitReached(selectionLimit.getHardLimit());
@ -682,63 +635,58 @@ public final class ContactSelectionListFragment extends LoggingFragment
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());
SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> {
return UsernameUtil.fetchAciForUsername(contact.getNumber());
return UsernameUtil.fetchAciForUsername(username);
}, uuid -> {
loadingDialog.dismiss();
if (uuid.isPresent()) {
Recipient recipient = Recipient.externalUsername(uuid.get(), contact.getNumber());
SelectedContact selected = SelectedContact.forUsername(recipient.getId(), contact.getNumber());
Recipient recipient = Recipient.externalUsername(uuid.get(), username);
SelectedContact selected = SelectedContact.forUsername(recipient.getId(), username);
if (onContactSelectedListener != null) {
onContactSelectedListener.onBeforeContactSelected(Optional.of(recipient.getId()), null, allowed -> {
if (allowed) {
markContactSelected(selected);
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
}
});
} else {
markContactSelected(selected);
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
}
} else {
new MaterialAlertDialogBuilder(requireContext())
.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())
.show();
}
});
} else {
if (onContactSelectedListener != null) {
onContactSelectedListener.onBeforeContactSelected(contact.getRecipientId(), contact.getNumber(), allowed -> {
onContactSelectedListener.onBeforeContactSelected(Optional.ofNullable(selectedContact.getRecipientId()), selectedContact.getNumber(), allowed -> {
if (allowed) {
markContactSelected(selectedContact);
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
}
});
} else {
markContactSelected(selectedContact);
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
}
}
} else {
markContactUnselected(selectedContact);
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
if (onContactSelectedListener != null) {
onContactSelectedListener.onContactDeselected(contact.getRecipientId(), contact.getNumber());
onContactSelectedListener.onContactDeselected(Optional.ofNullable(selectedContact.getRecipientId()), selectedContact.getNumber());
}
}
}
@Override
public boolean onItemLongClick(ContactSelectionListItem item) {
public boolean onItemLongClick(View anchorView, ContactSearchKey item) {
if (onItemLongClickListener != null) {
return onItemLongClickListener.onLongClick(item, recyclerView);
return onItemLongClickListener.onLongClick(anchorView, item, recyclerView);
} else {
return false;
}
@ -758,7 +706,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
}
private void markContactSelected(@NonNull SelectedContact selectedContact) {
cursorRecyclerViewAdapter.addSelectedContact(selectedContact);
contactSearchMediator.setKeysSelected(Collections.singleton(selectedContact.toContactSearchKey()));
if (isMulti) {
addChipForSelectedContact(selectedContact);
}
@ -768,8 +716,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
}
private void markContactUnselected(@NonNull SelectedContact selectedContact) {
cursorRecyclerViewAdapter.removeFromSelectedContacts(selectedContact);
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
contactSearchMediator.setKeysNotSelected(Collections.singleton(selectedContact.toContactSearchKey()));
contactChipViewModel.remove(selectedContact);
if (onContactSelectedListener != null) {
@ -842,6 +789,116 @@ public final class ContactSelectionListFragment extends LoggingFragment
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 {
/**
* 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 {
boolean onLongClick(ContactSelectionListItem contactSelectionListItem, RecyclerView recyclerView);
}
public interface AbstractContactsCursorLoaderFactoryProvider {
@NonNull AbstractContactsCursorLoader.Factory get();
boolean onLongClick(View anchorView, ContactSearchKey contactSearchKey, RecyclerView recyclerView);
}
}

View file

@ -23,7 +23,7 @@ import androidx.interpolator.view.animation.FastOutSlowInInterpolator;
import org.thoughtcrime.securesms.components.ContactFilterView;
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.database.SignalDatabase;
import org.thoughtcrime.securesms.groups.SelectionLimits;
@ -62,7 +62,7 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
@Override
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.HIDE_COUNT, true);
getIntent().putExtra(ContactSelectionListFragment.REFRESHABLE, false);

View file

@ -20,6 +20,7 @@ import android.content.Intent;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
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.management.ContactsManagementRepository;
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.conversation.ConversationIntents;
import org.thoughtcrime.securesms.database.SignalDatabase;
@ -233,18 +236,14 @@ public class NewConversationActivity extends ContactSelectionActivity
}
@Override
public boolean onLongClick(ContactSelectionListItem contactSelectionListItem, RecyclerView recyclerView) {
RecipientId recipientId = contactSelectionListItem.getRecipientId().orElse(null);
if (recipientId == null) {
return false;
}
public boolean onLongClick(View anchorView, ContactSearchKey contactSearchKey, RecyclerView recyclerView) {
RecipientId recipientId = contactSearchKey.requireRecipientSearchKey().getRecipientId();
List<ActionItem> actions = generateContextualActionsForRecipient(recipientId);
if (actions.isEmpty()) {
return false;
}
new SignalContextMenu.Builder(contactSelectionListItem, (ViewGroup) contactSelectionListItem.getRootView())
new SignalContextMenu.Builder(anchorView, (ViewGroup) anchorView.getRootView())
.preferredVerticalPosition(SignalContextMenu.VerticalPosition.BELOW)
.preferredHorizontalPosition(SignalContextMenu.HorizontalPosition.START)
.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.R;
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.RecipientId;
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.HIDE_COUNT, true);
intent.putExtra(ContactSelectionListFragment.DISPLAY_MODE,
ContactsCursorLoader.DisplayMode.FLAG_PUSH |
ContactsCursorLoader.DisplayMode.FLAG_SMS |
ContactsCursorLoader.DisplayMode.FLAG_ACTIVE_GROUPS |
ContactsCursorLoader.DisplayMode.FLAG_INACTIVE_GROUPS |
ContactsCursorLoader.DisplayMode.FLAG_BLOCK);
ContactSelectionDisplayMode.FLAG_PUSH |
ContactSelectionDisplayMode.FLAG_SMS |
ContactSelectionDisplayMode.FLAG_ACTIVE_GROUPS |
ContactSelectionDisplayMode.FLAG_INACTIVE_GROUPS |
ContactSelectionDisplayMode.FLAG_BLOCK);
getSupportFragmentManager().beginTransaction()
.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.R
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.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.RecipientId
@ -100,17 +100,17 @@ class SelectRecipientsFragment : LoggingFragment(), ContactSelectionListFragment
}
private fun getDefaultDisplayMode(): Int {
var mode = ContactsCursorLoader.DisplayMode.FLAG_PUSH or
ContactsCursorLoader.DisplayMode.FLAG_ACTIVE_GROUPS or
ContactsCursorLoader.DisplayMode.FLAG_HIDE_NEW or
ContactsCursorLoader.DisplayMode.FLAG_HIDE_RECENT_HEADER or
ContactsCursorLoader.DisplayMode.FLAG_GROUPS_AFTER_CONTACTS
var mode = ContactSelectionDisplayMode.FLAG_PUSH or
ContactSelectionDisplayMode.FLAG_ACTIVE_GROUPS or
ContactSelectionDisplayMode.FLAG_HIDE_NEW or
ContactSelectionDisplayMode.FLAG_HIDE_RECENT_HEADER or
ContactSelectionDisplayMode.FLAG_GROUPS_AFTER_CONTACTS
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>) {

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.SharedMediaPreference
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.groups.ParcelableGroupId
import org.thoughtcrime.securesms.groups.ui.GroupErrors
@ -768,7 +768,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
AddMembersActivity.createIntent(
requireContext(),
addMembersToGroup.groupId,
ContactsCursorLoader.DisplayMode.FLAG_PUSH,
ContactSelectionDisplayMode.FLAG_PUSH,
addMembersToGroup.selectionWarning,
addMembersToGroup.selectionLimit,
addMembersToGroup.isAnnouncementGroup,

View file

@ -37,7 +37,7 @@ public class ContactRepository {
private final Context context;
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_TYPE_COLUMN = "number_type";
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.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.RecipientId;
@ -46,10 +48,30 @@ public final class SelectedContact {
}
}
public @Nullable RecipientId getRecipientId() {
return recipientId;
}
public @Nullable String getNumber() {
return number;
}
public boolean hasUsername() {
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.
* <p>

View file

@ -1,15 +1,18 @@
package org.thoughtcrime.securesms.contacts.paged
import android.content.Context
import android.view.View
import android.view.ViewGroup
import android.widget.CheckBox
import android.widget.TextView
import com.google.android.material.button.MaterialButton
import org.signal.core.util.BreakIteratorCompat
import org.signal.core.util.dp
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.components.AvatarImageView
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.SignalContextMenu
import org.thoughtcrime.securesms.contacts.LetterHeaderDecoration
@ -31,17 +34,35 @@ import org.thoughtcrime.securesms.util.visible
*/
@Suppress("LeakingThis")
open class ContactSearchAdapter(
private val context: Context,
fixedContacts: Set<ContactSearchKey>,
displayCheckBox: Boolean,
displaySmsTag: DisplaySmsTag,
displayPhoneNumber: DisplayPhoneNumber,
onClickCallbacks: ClickCallbacks,
longClickCallbacks: LongClickCallbacks,
storyContextMenuCallbacks: StoryContextMenuCallbacks
) : PagingMappingAdapter<ContactSearchKey>() {
) : PagingMappingAdapter<ContactSearchKey>(), FastScrollAdapter {
init {
registerStoryItems(this, displayCheckBox, onClickCallbacks::onStoryClicked, storyContextMenuCallbacks)
registerKnownRecipientItems(this, displayCheckBox, displaySmsTag, onClickCallbacks::onKnownRecipientClicked)
registerKnownRecipientItems(this, fixedContacts, displayCheckBox, displaySmsTag, displayPhoneNumber, onClickCallbacks::onKnownRecipientClicked, longClickCallbacks::onKnownRecipientLongClick)
registerHeaders(this)
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 {
@ -59,13 +80,16 @@ open class ContactSearchAdapter(
fun registerKnownRecipientItems(
mappingAdapter: MappingAdapter,
fixedContacts: Set<ContactSearchKey>,
displayCheckBox: Boolean,
displaySmsTag: DisplaySmsTag,
recipientListener: OnClickedCallback<ContactSearchData.KnownRecipient>
displayPhoneNumber: DisplayPhoneNumber,
recipientListener: OnClickedCallback<ContactSearchData.KnownRecipient>,
recipientLongClickCallback: OnLongClickedCallback<ContactSearchData.KnownRecipient>
) {
mappingAdapter.registerFactory(
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.Empty -> EmptyModel(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)
}
}
else -> context.resources.getQuantityString(R.plurals.ContactSearchItems__custom_story_d_viewers, count, count)
}
}
@ -242,7 +268,32 @@ open class ContactSearchAdapter(
/**
* 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 {
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(
itemView: View,
private val fixedContacts: Set<ContactSearchKey>,
displayCheckBox: Boolean,
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 {
private var headerLetter: String? = null
@ -282,6 +369,9 @@ open class ContactSearchAdapter(
val count = recipient.participantIds.size
number.text = context.resources.getQuantityString(R.plurals.ContactSearchItems__group_d_members, count, count)
number.visible = true
} else if (displayPhoneNumber == DisplayPhoneNumber.ALWAYS && recipient.hasE164()) {
number.text = recipient.requireE164()
number.visible = true
} else {
super.bindNumberField(model)
}
@ -289,15 +379,22 @@ open class ContactSearchAdapter(
headerLetter = model.knownRecipient.headerLetter
}
override fun bindCheckbox(model: RecipientModel) {
checkbox.isEnabled = !fixedContacts.contains(model.knownRecipient.contactSearchKey)
}
override fun getHeaderLetter(): String? {
return headerLetter
}
override fun bindLongPress(model: RecipientModel) {
itemView.setOnLongClickListener { onLongClick.onLongClicked(itemView, model.knownRecipient) }
}
}
/**
* Base Recipient View Holder
*/
abstract class BaseRecipientViewHolder<T : MappingModel<T>, D : ContactSearchData>(
itemView: View,
private val displayCheckBox: Boolean,
@ -316,6 +413,7 @@ open class ContactSearchAdapter(
override fun bind(model: T) {
checkbox.visible = displayCheckBox
checkbox.isChecked = isSelected(model)
itemView.setOnClickListener { onClick.onClicked(avatar, getData(model), isSelected(model)) }
bindLongPress(model)
@ -332,6 +430,8 @@ open class ContactSearchAdapter(
bindSmsTagField(model)
}
protected open fun bindCheckbox(model: T) = Unit
protected open fun bindAvatar(model: T) {
avatar.setAvatar(getRecipient(model))
}
@ -444,12 +544,12 @@ open class ContactSearchAdapter(
ContactSearchConfiguration.SectionKey.RECENTS -> R.string.ContactsCursorLoader_recent_chats
ContactSearchConfiguration.SectionKey.INDIVIDUALS -> R.string.ContactsCursorLoader_contacts
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.CHATS -> R.string.ContactsCursorLoader__chats
ContactSearchConfiguration.SectionKey.MESSAGES -> R.string.ContactsCursorLoader__messages
ContactSearchConfiguration.SectionKey.GROUPS_WITH_MEMBERS -> R.string.ContactsCursorLoader_group_members
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
}
enum class DisplayPhoneNumber {
NEVER,
ALWAYS
}
fun interface OnClickedCallback<D : ContactSearchData> {
fun onClicked(view: View, data: D, isSelected: Boolean)
}
fun interface OnLongClickedCallback<D : ContactSearchData> {
fun onLongClicked(view: View, data: D): Boolean
}
interface ClickCallbacks {
fun onStoryClicked(view: View, story: ContactSearchData.Story, isSelected: Boolean)
fun onKnownRecipientClicked(view: View, knownRecipient: ContactSearchData.KnownRecipient, isSelected: Boolean)
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 expandConfig: ExpandConfig? = null
) : 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
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
}
/**
* Describes the mode for 'Username' or 'PhoneNumber'
*/
enum class NewRowMode {
NEW_CONVERSATION,
BLOCK,
ADD_TO_GROUP
}
companion object {
/**
* DSL Style builder function. Example:
@ -296,11 +325,12 @@ class ContactSearchConfiguration private constructor(
addSection(Section.Arbitrary(setOf(first) + rest.toSet()))
}
fun groupsWithMembers(
includeHeader: Boolean = true,
expandConfig: ExpandConfig? = null
) {
addSection(Section.GroupsWithMembers(includeHeader, expandConfig))
fun username(newRowMode: NewRowMode) {
addSection(Section.Username(newRowMode))
}
fun phone(newRowMode: NewRowMode) {
addSection(Section.PhoneNumber(newRowMode))
}
fun addSection(section: Section)

View file

@ -89,4 +89,13 @@ sealed class ContactSearchData(val contactSearchKey: ContactSearchKey) {
*/
@VisibleForTesting
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 kotlinx.parcelize.Parcelize
import org.thoughtcrime.securesms.contacts.SelectedContact
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.recipients.RecipientId
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 requireSelectedContact(): SelectedContact = error("This key cannot be converted into a SelectedContact")
@Parcelize
data class RecipientSearchKey(val recipientId: RecipientId, val isStory: Boolean) : ContactSearchKey(), Parcelable {
override fun requireRecipientSearchKey(): RecipientSearchKey = this
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
import android.content.Context
import android.view.View
import androidx.core.content.ContextCompat
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 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(
private val fragment: Fragment,
private val fixedContacts: Set<ContactSearchKey> = setOf(),
selectionLimits: SelectionLimits,
displayCheckBox: Boolean,
displaySmsTag: ContactSearchAdapter.DisplaySmsTag,
displayPhoneNumber: ContactSearchAdapter.DisplayPhoneNumber,
mapStateToConfiguration: (ContactSearchState) -> ContactSearchConfiguration,
private val contactSelectionPreFilter: (View?, Set<ContactSearchKey>) -> Set<ContactSearchKey> = { _, s -> s },
private val callbacks: Callbacks = SimpleCallbacks(),
performSafetyNumberChecks: Boolean = true,
adapterFactory: AdapterFactory = DefaultAdapterFactory,
arbitraryRepository: ArbitraryRepository? = null,
@ -49,8 +67,11 @@ class ContactSearchMediator(
)[ContactSearchViewModel::class.java]
val adapter = adapterFactory.create(
context = fragment.requireContext(),
fixedContacts = fixedContacts,
displayCheckBox = displayCheckBox,
displaySmsTag = displaySmsTag,
displayPhoneNumber = displayPhoneNumber,
callbacks = object : ContactSearchAdapter.ClickCallbacks {
override fun onStoryClicked(view: View, story: ContactSearchData.Story, isSelected: Boolean) {
toggleStorySelection(view, story, isSelected)
@ -64,6 +85,7 @@ class ContactSearchMediator(
viewModel.expandSection(expand.sectionKey)
}
},
longClickCallbacks = ContactSearchAdapter.LongClickCallbacksAdapter(),
storyContextMenuCallbacks = StoryContextMenuCallbacks()
)
@ -75,7 +97,9 @@ class ContactSearchMediator(
)
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 ->
@ -98,17 +122,28 @@ class ContactSearchMediator(
}
fun setKeysSelected(keys: Set<ContactSearchKey>) {
viewModel.setKeysSelected(contactSelectionPreFilter(null, keys))
viewModel.setKeysSelected(callbacks.onBeforeContactsSelected(null, keys))
}
fun setKeysNotSelected(keys: Set<ContactSearchKey>) {
keys.forEach {
callbacks.onContactDeselected(null, it)
}
viewModel.setKeysNotSelected(keys)
}
fun clearSelection() {
viewModel.clearSelection()
}
fun getSelectedContacts(): Set<ContactSearchKey> {
return viewModel.getSelectedContacts()
}
fun getFixedContactsSize(): Int {
return fixedContacts.size
}
fun getSelectionState(): LiveData<Set<ContactSearchKey>> {
return viewModel.selectionState
}
@ -135,9 +170,10 @@ class ContactSearchMediator(
private fun toggleSelection(view: View, contactSearchData: ContactSearchData, isSelected: Boolean) {
return if (isSelected) {
callbacks.onContactDeselected(view, contactSearchData.contactSearchKey)
viewModel.setKeysNotSelected(setOf(contactSearchData.contactSearchKey))
} 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
* be swapped for another implementation, allow listeners to be wrapped, etc.
*/
fun interface AdapterFactory {
fun create(
context: Context,
fixedContacts: Set<ContactSearchKey>,
displayCheckBox: Boolean,
displaySmsTag: ContactSearchAdapter.DisplaySmsTag,
displayPhoneNumber: ContactSearchAdapter.DisplayPhoneNumber,
callbacks: ContactSearchAdapter.ClickCallbacks,
longClickCallbacks: ContactSearchAdapter.LongClickCallbacks,
storyContextMenuCallbacks: ContactSearchAdapter.StoryContextMenuCallbacks
): PagingMappingAdapter<ContactSearchKey>
}
private object DefaultAdapterFactory : AdapterFactory {
override fun create(
context: Context,
fixedContacts: Set<ContactSearchKey>,
displayCheckBox: Boolean,
displaySmsTag: ContactSearchAdapter.DisplaySmsTag,
displayPhoneNumber: ContactSearchAdapter.DisplayPhoneNumber,
callbacks: ContactSearchAdapter.ClickCallbacks,
longClickCallbacks: ContactSearchAdapter.LongClickCallbacks,
storyContextMenuCallbacks: ContactSearchAdapter.StoryContextMenuCallbacks
): 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 org.signal.core.util.requireLong
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.ContactSearchIterator
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.ThreadRecord
import org.thoughtcrime.securesms.keyvalue.StorySend
import org.thoughtcrime.securesms.phonenumbers.NumberUtil
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.search.MessageResult
import org.thoughtcrime.securesms.search.MessageSearchResult
import org.thoughtcrime.securesms.search.SearchRepository
import org.thoughtcrime.securesms.search.ThreadSearchResult
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.UsernameUtil
import java.util.concurrent.TimeUnit
/**
@ -116,6 +120,8 @@ class ContactSearchPagedDataSource(
is ContactSearchConfiguration.Section.Messages -> getMessageData(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.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.GroupsWithMembers -> getGroupsWithMembersContactData(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> {
return when (section.transportType) {
ContactSearchConfiguration.TransportType.PUSH -> CursorSearchIterator(contactSearchPagedDataSourceRepository.querySignalContacts(query, section.includeSelf))
ContactSearchConfiguration.TransportType.SMS -> CursorSearchIterator(contactSearchPagedDataSourceRepository.queryNonSignalContacts(query))
ContactSearchConfiguration.TransportType.ALL -> CursorSearchIterator(contactSearchPagedDataSourceRepository.queryNonGroupContacts(query, section.includeSelf))
ContactSearchConfiguration.TransportType.PUSH -> CursorSearchIterator(wrapRecipientCursor(contactSearchPagedDataSourceRepository.querySignalContacts(query, section.includeSelf)))
ContactSearchConfiguration.TransportType.SMS -> CursorSearchIterator(wrapRecipientCursor(contactSearchPagedDataSourceRepository.queryNonSignalContacts(query)))
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> {
return when (section.transportType) {
ContactSearchConfiguration.TransportType.PUSH -> contactSearchPagedDataSourceRepository.querySignalContactLetterHeaders(query, section.includeSelf)

View file

@ -112,6 +112,10 @@ class ContactSearchViewModel(
return selectionStore.state
}
fun clearSelection() {
selectionStore.update { emptySet() }
}
fun addToVisibleGroupStories(groupStories: Set<ContactSearchKey.RecipientSearchKey>) {
disposables += contactSearchRepository.markDisplayAsStory(groupStories.map { it.recipientId }).subscribe {
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 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.recipients.RecipientId
data class ContactSelectionArguments(
val displayMode: Int = ContactsCursorLoader.DisplayMode.FLAG_ALL,
val displayMode: Int = ContactSelectionDisplayMode.FLAG_ALL,
val isRefreshable: Boolean = true,
val displayRecents: Boolean = false,
val selectionLimits: SelectionLimits? = null,

View file

@ -120,11 +120,17 @@ class MultiselectForwardFragment :
contactSearchRecycler = view.findViewById(R.id.contact_selection_list)
contactSearchMediator = ContactSearchMediator(
this,
emptySet(),
FeatureFlags.shareSelectionLimit(),
!args.selectSingleRecipient,
ContactSearchAdapter.DisplaySmsTag.DEFAULT,
ContactSearchAdapter.DisplayPhoneNumber.NEVER,
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

View file

@ -294,22 +294,32 @@ public class ConversationListFragment extends MainFragment implements ActionMode
cameraFab.setVisibility(View.VISIBLE);
contactSearchMediator = new ContactSearchMediator(this,
Collections.emptySet(),
SelectionLimits.NO_LIMITS,
false,
ContactSearchAdapter.DisplaySmsTag.DEFAULT,
ContactSearchAdapter.DisplayPhoneNumber.NEVER,
this::mapSearchStateToConfiguration,
(v, s) -> s,
new ContactSearchMediator.SimpleCallbacks(),
false,
(displayCheckBox,
(context,
fixedContacts,
displayCheckBox,
displaySmsTag,
displayPhoneNumber,
callbacks,
longClickCallbacks,
storyContextMenuCallbacks
) -> {
//noinspection CodeBlock2Expr
return new ConversationListSearchAdapter(
context,
fixedContacts,
displayCheckBox,
displaySmsTag,
displayPhoneNumber,
new ContactSearchClickCallbacks(callbacks),
longClickCallbacks,
storyContextMenuCallbacks,
getViewLifecycleOwner(),
GlideApp.with(this)
@ -1894,6 +1904,11 @@ public class ConversationListFragment extends MainFragment implements ActionMode
public void onExpandClicked(@NonNull ContactSearchData.Expand 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 {

View file

@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.conversationlist
import android.content.Context
import android.view.View
import android.widget.TextView
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.ContactSearchConfiguration
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.mms.GlideRequests
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.
*/
class ConversationListSearchAdapter(
context: Context,
fixedContacts: Set<ContactSearchKey>,
displayCheckBox: Boolean,
displaySmsTag: DisplaySmsTag,
displayPhoneNumber: DisplayPhoneNumber,
onClickedCallbacks: ConversationListSearchClickCallbacks,
longClickCallbacks: LongClickCallbacks,
storyContextMenuCallbacks: StoryContextMenuCallbacks,
lifecycleOwner: LifecycleOwner,
glideRequests: GlideRequests
) : ContactSearchAdapter(displayCheckBox, displaySmsTag, onClickedCallbacks, storyContextMenuCallbacks) {
) : ContactSearchAdapter(context, fixedContacts, displayCheckBox, displaySmsTag, displayPhoneNumber, onClickedCallbacks, longClickCallbacks, storyContextMenuCallbacks) {
init {
registerFactory(

View file

@ -4362,7 +4362,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
SELECT 1
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}
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()
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.ContactSelectionListFragment;
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.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(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));

View file

@ -17,7 +17,7 @@ import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.ContactSelectionActivity;
import org.thoughtcrime.securesms.ContactSelectionListFragment;
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.database.RecipientTable;
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);
boolean smsEnabled = SignalStore.misc().getSmsExportPhase().allowSmsFeatures();
int displayMode = smsEnabled ? ContactsCursorLoader.DisplayMode.FLAG_SMS | ContactsCursorLoader.DisplayMode.FLAG_PUSH
: ContactsCursorLoader.DisplayMode.FLAG_PUSH;
int displayMode = smsEnabled ? ContactSelectionDisplayMode.FLAG_SMS | ContactSelectionDisplayMode.FLAG_PUSH
: ContactSelectionDisplayMode.FLAG_PUSH;
intent.putExtra(ContactSelectionListFragment.DISPLAY_MODE, displayMode);
intent.putExtra(ContactSelectionListFragment.SELECTION_LIMITS, FeatureFlags.groupLimits().excludingSelf());

View file

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

View file

@ -15,7 +15,7 @@ import org.thoughtcrime.securesms.ContactSelectionListFragment;
import org.thoughtcrime.securesms.LoggingFragment;
import org.thoughtcrime.securesms.R;
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.database.SignalDatabase;
import org.thoughtcrime.securesms.payments.CanNotSendPaymentDialog;
@ -49,7 +49,7 @@ public class PaymentRecipientSelectionFragment extends LoggingFragment implement
Bundle arguments = new Bundle();
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);
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),
displayCheckBox = false,
displaySmsTag = ContactSearchAdapter.DisplaySmsTag.IF_NOT_REGISTERED,
displayPhoneNumber = ContactSearchAdapter.DisplayPhoneNumber.NEVER,
mapStateToConfiguration = { getConfiguration() },
performSafetyNumberChecks = false
)

View file

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

View file

@ -80,6 +80,6 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
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>

View file

@ -1,46 +1,35 @@
<?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"
tools:viewBindingIgnore="true"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/rounded_inset_ripple_background"
android:background="@drawable/conversation_item_background"
android:focusable="true"
android:minHeight="@dimen/contact_selection_item_height"
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:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/SingleContactSelectionActivity_contact_photo"
android:cropToPadding="true"
android:foreground="@drawable/contact_photo_background"
android:background="@color/signal_colorSurfaceVariant"
android:importantForAccessibility="no"
app:contentPadding="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="UnusedAttribute"
tools:src="@color/blue_600" />
<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" />
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Signal.Circle"
app:srcCompat="@drawable/ic_search_24"
tools:ignore="UnusedAttribute" />
<androidx.appcompat.widget.AppCompatCheckBox
android:id="@+id/check_box"
android:layout_width="24dp"
android:layout_height="24dp"
android:background="@drawable/contact_selection_checkbox"
android:background="?contactCheckboxBackground"
android:button="@null"
android:clickable="false"
android:focusable="false"
@ -58,10 +47,10 @@
android:drawablePadding="4dp"
android:ellipsize="marquee"
android:singleLine="true"
android:textAppearance="@style/Signal.Text.BodyLarge"
android:textAppearance="@style/TextAppearance.Signal.Body1"
android:textColor="@color/signal_text_primary"
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_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"
@ -76,47 +65,17 @@
android:layout_marginStart="16dp"
android:ellipsize="marquee"
android:singleLine="true"
android:textAppearance="@style/Signal.Text.BodyMedium"
android:textAppearance="@style/TextAppearance.Signal.Body2"
android:textColor="@color/signal_text_secondary"
android:textDirection="ltr"
app:emoji_forceCustom="true"
app:layout_constrainedWidth="true"
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_chainStyle="packed"
app:layout_constraintStart_toEndOf="@id/contact_photo_image"
app:layout_constraintTop_toBottomOf="@id/name"
tools:text="@sample/contacts.json/data/number" />
<TextView
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>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

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

View file

@ -30,12 +30,13 @@ class ContactSearchPagedDataSourceTest {
@Before
fun setUp() {
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.getRecipientFromDistributionListCursor(cursor)).thenReturn(Recipient.UNKNOWN)
whenever(repository.getPrivacyModeFromDistributionListCursor(cursor)).thenReturn(DistributionListPrivacyMode.ALL)
whenever(repository.getGroupStories()).thenReturn(emptySet())
whenever(repository.getLatestStorySends(any())).thenReturn(emptyList())
whenever(cursor.getString(any())).thenReturn("A")
whenever(cursor.moveToPosition(any())).thenCallRealMethod()
whenever(cursor.moveToNext()).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)
}
}