diff --git a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionActivity.java b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionActivity.java index 8e48af086a..0c9fe3da23 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionActivity.java @@ -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); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListAdapter.kt new file mode 100644 index 0000000000..bdf7016589 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListAdapter.kt @@ -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 { + override fun areItemsTheSame(newItem: NewGroupModel): Boolean = true + override fun areContentsTheSame(newItem: NewGroupModel): Boolean = true + } + + class InviteToSignalModel : MappingModel { + 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 { + 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() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java index 1a2d924f6c..d953be6fb3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java @@ -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 { @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 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 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 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 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 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 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 contacts) { + public void markSelected(@NonNull Set contacts) { if (contacts.isEmpty()) { return; } Set 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); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/InviteActivity.java b/app/src/main/java/org/thoughtcrime/securesms/InviteActivity.java index e47bfa6e20..5b48afe4c6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/InviteActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/InviteActivity.java @@ -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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/NewConversationActivity.java b/app/src/main/java/org/thoughtcrime/securesms/NewConversationActivity.java index 5c81986772..b3c8bacaf2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/NewConversationActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/NewConversationActivity.java @@ -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 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)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/StaticMappingViewHolder.kt b/app/src/main/java/org/thoughtcrime/securesms/StaticMappingViewHolder.kt new file mode 100644 index 0000000000..b1f4bea4cc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/StaticMappingViewHolder.kt @@ -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>(itemView: View, onClickListener: () -> Unit) : MappingViewHolder(itemView) { + init { + itemView.setOnClickListener { onClickListener() } + } + + override fun bind(model: T) = Unit +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersActivity.java b/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersActivity.java index ce6776e443..049faf8e98 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersActivity.java @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/profiles/SelectRecipientsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/profiles/SelectRecipientsFragment.kt index 83a0870d86..be881b6c34 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/profiles/SelectRecipientsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/profiles/SelectRecipientsFragment.kt @@ -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, number: String?, callback: Consumer) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt index 6c94998d9b..96bae01165 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt @@ -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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactRepository.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactRepository.java index e46f1dd159..c7b6b9bf84 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactRepository.java @@ -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"; diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionDisplayMode.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionDisplayMode.java new file mode 100644 index 0000000000..a4b4789735 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionDisplayMode.java @@ -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; +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.java deleted file mode 100644 index b106630380..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.java +++ /dev/null @@ -1,448 +0,0 @@ -/** - * Copyright (C) 2014 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 . - */ -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 - implements FastScrollAdapter, - StickyHeaderAdapter -{ - @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 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 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 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 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 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 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); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorLoader.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorLoader.java deleted file mode 100644 index 409eac08bb..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorLoader.java +++ /dev/null @@ -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 . - */ -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 getUnfilteredResults() { - ArrayList 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 getFilteredResults() { - ArrayList cursorList = new ArrayList<>(); - - addContactsSection(cursorList); - addGroupsSection(cursorList); - - if (!hideNewNumberOrUsername(mode)) { - addNewNumberSection(cursorList); - addUsernameSearchSection(cursorList); - } - - return cursorList; - } - - private void addRecentsSection(@NonNull List 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 cursorList) { - List contacts = getContactsCursors(); - - if (!isCursorListEmpty(contacts)) { - if (!getFilter().isEmpty() || recents) { - cursorList.add(ContactsCursorRows.forContactsHeader(getContext())); - } - cursorList.addAll(contacts); - } - } - - private void addRecentGroupsSection(@NonNull List 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 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 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 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 getContactsCursors() { - List 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 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 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 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); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorRows.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorRows.java deleted file mode 100644 index 14803b3a45..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorRows.java +++ /dev/null @@ -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; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectedContact.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectedContact.java index 2c35a07f57..9957082356 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectedContact.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectedContact.java @@ -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. *

diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchAdapter.kt index 0e3a2c5a00..93e5faaace 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchAdapter.kt @@ -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, displayCheckBox: Boolean, displaySmsTag: DisplaySmsTag, + displayPhoneNumber: DisplayPhoneNumber, onClickCallbacks: ClickCallbacks, + longClickCallbacks: LongClickCallbacks, storyContextMenuCallbacks: StoryContextMenuCallbacks -) : PagingMappingAdapter() { +) : PagingMappingAdapter(), 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, displayCheckBox: Boolean, displaySmsTag: DisplaySmsTag, - recipientListener: OnClickedCallback + displayPhoneNumber: DisplayPhoneNumber, + recipientListener: OnClickedCallback, + recipientLongClickCallback: OnLongClickedCallback ) { 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 { + class RecipientModel( + val knownRecipient: ContactSearchData.KnownRecipient, + val isSelected: Boolean, + val shortSummary: Boolean + ) : MappingModel, 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 { + 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, + private val displayCheckBox: Boolean + ) : MappingViewHolder(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, displayCheckBox: Boolean, displaySmsTag: DisplaySmsTag, - onClick: OnClickedCallback + private val displayPhoneNumber: DisplayPhoneNumber, + onClick: OnClickedCallback, + private val onLongClick: OnLongClickedCallback ) : BaseRecipientViewHolder(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, 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 { fun onClicked(view: View, data: D, isSelected: Boolean) } + fun interface OnLongClickedCallback { + 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 } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchConfiguration.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchConfiguration.kt index 45a934e075..837345b1ec 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchConfiguration.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchConfiguration.kt @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchData.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchData.kt index 1c4a6a1c6d..36af4f2aa4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchData.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchData.kt @@ -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)) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchKey.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchKey.kt index da64b69591..7b9dbd051e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchKey.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchKey.kt @@ -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") + } } /** diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchMediator.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchMediator.kt index 3df22a39e1..9ea4ac772e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchMediator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchMediator.kt @@ -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 = setOf(), selectionLimits: SelectionLimits, displayCheckBox: Boolean, displaySmsTag: ContactSearchAdapter.DisplaySmsTag, + displayPhoneNumber: ContactSearchAdapter.DisplayPhoneNumber, mapStateToConfiguration: (ContactSearchState) -> ContactSearchConfiguration, - private val contactSelectionPreFilter: (View?, Set) -> Set = { _, 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) { - viewModel.setKeysSelected(contactSelectionPreFilter(null, keys)) + viewModel.setKeysSelected(callbacks.onBeforeContactsSelected(null, keys)) } fun setKeysNotSelected(keys: Set) { + keys.forEach { + callbacks.onContactDeselected(null, it) + } viewModel.setKeysNotSelected(keys) } + fun clearSelection() { + viewModel.clearSelection() + } + fun getSelectedContacts(): Set { return viewModel.getSelectedContacts() } + fun getFixedContactsSize(): Int { + return fixedContacts.size + } + fun getSelectionState(): LiveData> { 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): Set + fun onContactDeselected(view: View?, contactSearchKey: ContactSearchKey) + fun onAdapterListCommitted(size: Int) + } + + open class SimpleCallbacks : Callbacks { + override fun onBeforeContactsSelected(view: View?, contactSearchKeys: Set): Set { + return contactSearchKeys + } + + override fun onContactDeselected(view: View?, contactSearchKey: ContactSearchKey) = Unit + override fun onAdapterListCommitted(size: Int) = Unit + } + /** * Wraps the construction of a PagingMappingAdapter so that it can * be swapped for another implementation, allow listeners to be wrapped, etc. */ fun interface AdapterFactory { fun create( + context: Context, + fixedContacts: Set, displayCheckBox: Boolean, displaySmsTag: ContactSearchAdapter.DisplaySmsTag, + displayPhoneNumber: ContactSearchAdapter.DisplayPhoneNumber, callbacks: ContactSearchAdapter.ClickCallbacks, + longClickCallbacks: ContactSearchAdapter.LongClickCallbacks, storyContextMenuCallbacks: ContactSearchAdapter.StoryContextMenuCallbacks ): PagingMappingAdapter } private object DefaultAdapterFactory : AdapterFactory { override fun create( + context: Context, + fixedContacts: Set, displayCheckBox: Boolean, displaySmsTag: ContactSearchAdapter.DisplaySmsTag, + displayPhoneNumber: ContactSearchAdapter.DisplayPhoneNumber, callbacks: ContactSearchAdapter.ClickCallbacks, + longClickCallbacks: ContactSearchAdapter.LongClickCallbacks, storyContextMenuCallbacks: ContactSearchAdapter.StoryContextMenuCallbacks ): PagingMappingAdapter { - return ContactSearchAdapter(displayCheckBox, displaySmsTag, callbacks, storyContextMenuCallbacks) + return ContactSearchAdapter(context, fixedContacts, displayCheckBox, displaySmsTag, displayPhoneNumber, callbacks, longClickCallbacks, storyContextMenuCallbacks) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt index 7b177b0ccd..59c41752e0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt @@ -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 { + return if (isPossiblyPhoneNumber(query)) { + listOf(ContactSearchData.UnknownRecipient(section.sectionKey, section.newRowMode, query!!)) + } else { + emptyList() + } + } + private fun getPossibleUsername(section: ContactSearchConfiguration.Section.Username, query: String?): List { + return if (isPossiblyUsername(query)) { + listOf(ContactSearchData.UnknownRecipient(section.sectionKey, section.newRowMode, query!!)) + } else { + emptyList() } } private fun getNonGroupSearchIterator(section: ContactSearchConfiguration.Section.Individuals, query: String?): ContactSearchIterator { 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 { return when (section.transportType) { ContactSearchConfiguration.TransportType.PUSH -> contactSearchPagedDataSourceRepository.querySignalContactLetterHeaders(query, section.includeSelf) diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchViewModel.kt index 688db76242..56548e456e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchViewModel.kt @@ -112,6 +112,10 @@ class ContactSearchViewModel( return selectionStore.state } + fun clearSelection() { + selectionStore.update { emptySet() } + } + fun addToVisibleGroupStories(groupStories: Set) { disposables += contactSearchRepository.markDisplayAsStory(groupStories.map { it.recipientId }).subscribe { configurationStore.update { state -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/WrapAroundCursor.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/WrapAroundCursor.kt new file mode 100644 index 0000000000..559f93bf9f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/WrapAroundCursor.kt @@ -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 + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/selection/ContactSelectionArguments.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/selection/ContactSelectionArguments.kt index bff1ab9d9c..ff8206d040 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/selection/ContactSelectionArguments.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/selection/ContactSelectionArguments.kt @@ -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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragment.kt index 66f80e423c..8de35671e4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragment.kt @@ -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): Set { + return filterContacts(view, contactSearchKeys) + } + } ) contactSearchRecycler.adapter = contactSearchMediator.adapter diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java index 1d33371f1c..0246db8394 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListSearchAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListSearchAdapter.kt index adc97af7f5..fdef0a6cae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListSearchAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListSearchAdapter.kt @@ -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, 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( diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt index f3ccafca68..3236e0793a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt @@ -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" diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addtogroup/AddToGroupsActivity.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addtogroup/AddToGroupsActivity.java index 3c55b64bee..f0011718e3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addtogroup/AddToGroupsActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addtogroup/AddToGroupsActivity.java @@ -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)); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupActivity.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupActivity.java index bfafc14193..599c7adbbc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupActivity.java @@ -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()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/stories/ChooseGroupStoryBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/stories/ChooseGroupStoryBottomSheet.kt index 03902cb433..926153d19c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/stories/ChooseGroupStoryBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/stories/ChooseGroupStoryBottomSheet.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentRecipientSelectionFragment.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentRecipientSelectionFragment.java index 7fbc17d082..d8e6fb47d9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentRecipientSelectionFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentRecipientSelectionFragment.java @@ -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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/connections/ViewAllSignalConnectionsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/connections/ViewAllSignalConnectionsFragment.kt index e3060e8b9d..ba53a4c19b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/connections/ViewAllSignalConnectionsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/connections/ViewAllSignalConnectionsFragment.kt @@ -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 ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/select/BaseStoryRecipientSelectionFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/select/BaseStoryRecipientSelectionFragment.kt index 560173a029..f78e9c26a9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/select/BaseStoryRecipientSelectionFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/select/BaseStoryRecipientSelectionFragment.kt @@ -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, diff --git a/app/src/main/res/layout/blocked_users_fragment.xml b/app/src/main/res/layout/blocked_users_fragment.xml index 29a6415c55..1e9cda0b36 100644 --- a/app/src/main/res/layout/blocked_users_fragment.xml +++ b/app/src/main/res/layout/blocked_users_fragment.xml @@ -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" /> \ No newline at end of file diff --git a/app/src/main/res/layout/contact_selection_list_item.xml b/app/src/main/res/layout/contact_search_unknown_item.xml similarity index 51% rename from app/src/main/res/layout/contact_selection_list_item.xml rename to app/src/main/res/layout/contact_search_unknown_item.xml index f6fb8cf0e8..f16affd240 100644 --- a/app/src/main/res/layout/contact_selection_list_item.xml +++ b/app/src/main/res/layout/contact_search_unknown_item.xml @@ -1,46 +1,35 @@ - + android:paddingEnd="@dimen/dsl_settings_gutter" + tools:viewBindingIgnore="true"> - - - + app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Signal.Circle" + app:srcCompat="@drawable/ic_search_24" + tools:ignore="UnusedAttribute" /> - - - - - + diff --git a/app/src/main/res/layout/contact_selection_list_fragment.xml b/app/src/main/res/layout/contact_selection_list_fragment.xml index 92cc5bbe02..5673b11e73 100644 --- a/app/src/main/res/layout/contact_selection_list_fragment.xml +++ b/app/src/main/res/layout/contact_selection_list_fragment.xml @@ -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" /> 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) + } +}