Group contact chips behind feature flag.

This commit is contained in:
Alan Evans
2020-04-24 16:12:33 -03:00
committed by Greyson Parrelli
parent 8e0fba7992
commit 0b279d1df3
13 changed files with 610 additions and 131 deletions

View File

@@ -28,6 +28,7 @@ import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.widget.Button;
import android.widget.HorizontalScrollView;
import android.widget.TextView;
import android.widget.Toast;
@@ -35,26 +36,32 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentActivity;
import androidx.loader.app.LoaderManager;
import androidx.loader.content.Loader;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import com.google.android.material.chip.ChipGroup;
import com.pnikosis.materialishprogress.ProgressWheel;
import org.thoughtcrime.securesms.components.RecyclerViewFastScroller;
import org.thoughtcrime.securesms.contacts.ContactChip;
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.SelectedContact;
import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.Debouncer;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
@@ -67,9 +74,7 @@ import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
import org.whispersystems.libsignal.util.guava.Optional;
import java.io.IOException;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
/**
* Fragment for selecting a one or more contacts from a list.
@@ -88,8 +93,9 @@ public final class ContactSelectionListFragment extends Fragment
public static final String REFRESHABLE = "refreshable";
public static final String RECENTS = "recents";
private final Debouncer scrollDebounce = new Debouncer(100);
private TextView emptyText;
private Set<SelectedContact> selectedContacts;
private OnContactSelectedListener onContactSelectedListener;
private SwipeRefreshLayout swipeRefresh;
private View showContactsLayout;
@@ -100,10 +106,13 @@ public final class ContactSelectionListFragment extends Fragment
private RecyclerView recyclerView;
private RecyclerViewFastScroller fastScroller;
private ContactSelectionListAdapter cursorRecyclerViewAdapter;
private ChipGroup chipGroup;
private HorizontalScrollView chipGroupScrollContainer;
@Nullable private FixedViewsAdapter headerAdapter;
@Nullable private FixedViewsAdapter footerAdapter;
@Nullable private ListCallback listCallback;
private GlideRequests glideRequests;
@Override
public void onAttach(@NonNull Context context) {
@@ -132,14 +141,16 @@ public final class ContactSelectionListFragment extends Fragment
if (!TextSecurePreferences.hasSuccessfullyRetrievedDirectory(getActivity())) {
handleContactPermissionGranted();
} else {
this.getLoaderManager().initLoader(0, null, this);
LoaderManager.getInstance(this).initLoader(0, null, this);
}
})
.onAnyDenied(() -> {
getActivity().getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN);
FragmentActivity activity = requireActivity();
if (getActivity().getIntent().getBooleanExtra(RECENTS, false)) {
getLoaderManager().initLoader(0, null, ContactSelectionListFragment.this);
activity.getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN);
if (activity.getIntent().getBooleanExtra(RECENTS, false)) {
LoaderManager.getInstance(this).initLoader(0, null, ContactSelectionListFragment.this);
} else {
initializeNoContactsPermission();
}
@@ -151,17 +162,22 @@ public final class ContactSelectionListFragment extends Fragment
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.contact_selection_list_fragment, container, false);
emptyText = ViewUtil.findById(view, android.R.id.empty);
recyclerView = ViewUtil.findById(view, R.id.recycler_view);
swipeRefresh = ViewUtil.findById(view, R.id.swipe_refresh);
fastScroller = ViewUtil.findById(view, R.id.fast_scroller);
showContactsLayout = view.findViewById(R.id.show_contacts_container);
showContactsButton = view.findViewById(R.id.show_contacts_button);
showContactsDescription = view.findViewById(R.id.show_contacts_description);
showContactsProgress = view.findViewById(R.id.progress);
emptyText = ViewUtil.findById(view, android.R.id.empty);
recyclerView = ViewUtil.findById(view, R.id.recycler_view);
swipeRefresh = ViewUtil.findById(view, R.id.swipe_refresh);
fastScroller = ViewUtil.findById(view, R.id.fast_scroller);
showContactsLayout = view.findViewById(R.id.show_contacts_container);
showContactsButton = view.findViewById(R.id.show_contacts_button);
showContactsDescription = view.findViewById(R.id.show_contacts_description);
showContactsProgress = view.findViewById(R.id.progress);
chipGroup = view.findViewById(R.id.chipGroup);
chipGroupScrollContainer = view.findViewById(R.id.chipGroupScrollContainer);
recyclerView.setLayoutManager(new LinearLayoutManager(getActivity()));
swipeRefresh.setEnabled(getActivity().getIntent().getBooleanExtra(REFRESHABLE, true));
swipeRefresh.setEnabled(requireActivity().getIntent().getBooleanExtra(REFRESHABLE, true));
autoScrollOnNewItem();
return view;
}
@@ -171,26 +187,22 @@ public final class ContactSelectionListFragment extends Fragment
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);
}
public @NonNull List<SelectedContact> getSelectedContacts() {
List<SelectedContact> selected = new LinkedList<>();
if (selectedContacts != null) {
selected.addAll(selectedContacts);
}
return selected;
@NonNull List<SelectedContact> getSelectedContacts() {
return cursorRecyclerViewAdapter.getSelectedContacts();
}
private boolean isMulti() {
return getActivity().getIntent().getBooleanExtra(MULTI_SELECT, false);
return requireActivity().getIntent().getBooleanExtra(MULTI_SELECT, false);
}
private void initializeCursor() {
glideRequests = GlideApp.with(this);
cursorRecyclerViewAdapter = new ContactSelectionListAdapter(requireContext(),
GlideApp.with(this),
glideRequests,
null,
new ListClickListener(),
isMulti());
selectedContacts = cursorRecyclerViewAdapter.getSelectedContacts();
RecyclerViewConcatenateAdapterStickyHeader concatenateAdapter = new RecyclerViewConcatenateAdapterStickyHeader();
@@ -263,7 +275,7 @@ public final class ContactSelectionListFragment extends Fragment
}
public void reset() {
selectedContacts.clear();
cursorRecyclerViewAdapter.clearSelectedContacts();
if (!isDetached() && !isRemoving() && getActivity() != null && !getActivity().isFinishing()) {
getLoaderManager().restartLoader(0, null, this);
@@ -356,7 +368,7 @@ public final class ContactSelectionListFragment extends Fragment
SelectedContact selectedContact = contact.isUsernameType() ? SelectedContact.forUsername(contact.getRecipientId().orNull(), contact.getNumber())
: SelectedContact.forPhone(contact.getRecipientId().orNull(), contact.getNumber());
if (!isMulti() || !selectedContacts.contains(selectedContact)) {
if (!isMulti() || !cursorRecyclerViewAdapter.isSelectedContact(selectedContact)) {
if (contact.isUsernameType()) {
AlertDialog loadingDialog = SimpleProgressDialog.show(requireContext());
@@ -366,8 +378,8 @@ public final class ContactSelectionListFragment extends Fragment
loadingDialog.dismiss();
if (uuid.isPresent()) {
Recipient recipient = Recipient.externalUsername(requireContext(), uuid.get(), contact.getNumber());
selectedContacts.add(SelectedContact.forUsername(recipient.getId(), contact.getNumber()));
contact.setChecked(true);
SelectedContact selected = SelectedContact.forUsername(recipient.getId(), contact.getNumber());
markContactSelected(selected, contact);
if (onContactSelectedListener != null) {
onContactSelectedListener.onContactSelected(Optional.of(recipient.getId()), null);
@@ -381,24 +393,66 @@ public final class ContactSelectionListFragment extends Fragment
}
});
} else {
selectedContacts.add(selectedContact);
contact.setChecked(true);
markContactSelected(selectedContact, contact);
if (onContactSelectedListener != null) {
onContactSelectedListener.onContactSelected(contact.getRecipientId(), contact.getNumber());
}
}
} else {
selectedContacts.remove(selectedContact);
contact.setChecked(false);
markContactUnselected(selectedContact, contact);
if (onContactSelectedListener != null) {
onContactSelectedListener.onContactDeselected(contact.getRecipientId(), contact.getNumber());
}
}}
}
private void markContactSelected(@NonNull SelectedContact selectedContact, @NonNull ContactSelectionListItem listItem) {
cursorRecyclerViewAdapter.addSelectedContact(selectedContact);
listItem.setChecked(true);
if (isMulti() && FeatureFlags.newGroupUI()) {
chipGroup.addView(newChipForContact(listItem, selectedContact));
}
}
private void markContactUnselected(@NonNull SelectedContact selectedContact, @NonNull ContactSelectionListItem listItem) {
cursorRecyclerViewAdapter.removeFromSelectedContacts(selectedContact);
listItem.setChecked(false);
removeChipForContact(selectedContact);
}
private void removeChipForContact(@NonNull SelectedContact contact) {
for (int i = chipGroup.getChildCount() - 1; i >= 0; i--) {
View v = chipGroup.getChildAt(i);
if (v instanceof ContactChip && contact.matches(((ContactChip) v).getContact())) {
chipGroup.removeView(v);
}
}
}
private View newChipForContact(@NonNull ContactSelectionListItem contact, @NonNull SelectedContact selectedContact) {
final ContactChip chip = new ContactChip(requireContext());
chip.setText(contact.getChipName());
chip.setContact(selectedContact);
LiveRecipient recipient = contact.getRecipient();
if (recipient != null) {
recipient.observe(getViewLifecycleOwner(), resolved -> {
chip.setAvatar(glideRequests, resolved);
chip.setText(resolved.getShortDisplayName(chip.getContext()));
}
);
}
chip.setCloseIconVisible(true);
chip.setOnCloseIconClickListener(view -> {
markContactUnselected(selectedContact, contact);
chipGroup.removeView(chip);
});
return chip;
}
public void setOnContactSelectedListener(OnContactSelectedListener onContactSelectedListener) {
this.onContactSelectedListener = onContactSelectedListener;
}
@@ -407,6 +461,19 @@ public final class ContactSelectionListFragment extends Fragment
this.swipeRefresh.setOnRefreshListener(onRefreshListener);
}
private void autoScrollOnNewItem() {
chipGroup.addOnLayoutChangeListener((view1, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> {
if (right > oldRight) {
scrollDebounce.publish(this::smoothScrollChipsToEnd);
}
});
}
private void smoothScrollChipsToEnd() {
int x = chipGroupScrollContainer.getLayoutDirection() == View.LAYOUT_DIRECTION_LTR ? chipGroup.getWidth() : 0;
chipGroupScrollContainer.smoothScrollTo(x, 0);
}
public interface OnContactSelectedListener {
void onContactSelected(Optional<RecipientId> recipientId, String number);
void onContactDeselected(Optional<RecipientId> recipientId, String number);

View File

@@ -22,8 +22,6 @@ import androidx.appcompat.widget.Toolbar;
import androidx.core.content.ContextCompat;
import androidx.interpolator.view.animation.FastOutSlowInInterpolator;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.components.ContactFilterToolbar;
import org.thoughtcrime.securesms.components.ContactFilterToolbar.OnFilterChangedListener;
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode;
@@ -42,6 +40,7 @@ import org.thoughtcrime.securesms.util.concurrent.ListenableFuture.Listener;
import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask;
import org.whispersystems.libsignal.util.guava.Optional;
import java.util.List;
import java.util.concurrent.ExecutionException;
public class InviteActivity extends PassphraseRequiredActionBarActivity implements ContactSelectionListFragment.OnContactSelectedListener {
@@ -135,14 +134,15 @@ public class InviteActivity extends PassphraseRequiredActionBarActivity implemen
new SendSmsInvitesAsyncTask(this, inviteText.getText().toString())
.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR,
contactsFragment.getSelectedContacts()
.toArray(new SelectedContact[contactsFragment.getSelectedContacts().size()]));
.toArray(new SelectedContact[0]));
}
private void updateSmsButtonText() {
List<SelectedContact> selectedContacts = contactsFragment.getSelectedContacts();
smsSendButton.setText(getResources().getQuantityString(R.plurals.InviteActivity_send_sms_to_friends,
contactsFragment.getSelectedContacts().size(),
contactsFragment.getSelectedContacts().size()));
smsSendButton.setEnabled(!contactsFragment.getSelectedContacts().isEmpty());
selectedContacts.size(),
selectedContacts.size()));
smsSendButton.setEnabled(!selectedContacts.isEmpty());
}
@Override public void onBackPressed() {

View File

@@ -0,0 +1,117 @@
package org.thoughtcrime.securesms.contacts;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.ColorFilter;
import android.graphics.PixelFormat;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.util.AttributeSet;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.bumptech.glide.request.target.CustomTarget;
import com.bumptech.glide.request.transition.Transition;
import com.google.android.material.chip.Chip;
import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.recipients.Recipient;
public final class ContactChip extends Chip {
@Nullable private SelectedContact contact;
public ContactChip(Context context) {
super(context);
}
public ContactChip(Context context, AttributeSet attrs) {
super(context, attrs);
}
public ContactChip(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public void setContact(@NonNull SelectedContact contact) {
this.contact = contact;
}
public @Nullable SelectedContact getContact() {
return contact;
}
public void setAvatar(@NonNull GlideRequests requestManager, @Nullable Recipient recipient) {
if (recipient != null) {
requestManager.clear(this);
Drawable fallbackContactPhotoDrawable = recipient.getFallbackContactPhotoDrawable(getContext(), false);
ContactPhoto contactPhoto = recipient.getContactPhoto();
if (contactPhoto == null) {
setChipIcon(new HalfScaleDrawable(fallbackContactPhotoDrawable));
} else {
requestManager.load(contactPhoto)
.fallback(fallbackContactPhotoDrawable)
.error(fallbackContactPhotoDrawable)
.diskCacheStrategy(DiskCacheStrategy.ALL)
.circleCrop()
.into(new CustomTarget<Drawable>() {
@Override
public void onResourceReady(@NonNull Drawable resource, @Nullable Transition<? super Drawable> transition) {
setChipIcon(resource);
}
@Override
public void onLoadCleared(@Nullable Drawable placeholder) {
setChipIcon(placeholder);
}
});
}
}
}
private static class HalfScaleDrawable extends Drawable {
private final Drawable fallbackContactPhotoDrawable;
HalfScaleDrawable(Drawable fallbackContactPhotoDrawable) {
this.fallbackContactPhotoDrawable = fallbackContactPhotoDrawable;
}
@Override
public void setBounds(int left, int top, int right, int bottom) {
super.setBounds(left, top, right, bottom);
fallbackContactPhotoDrawable.setBounds(left, top, 2 * right - left, 2 * bottom - top);
}
@Override
public void setBounds(@NonNull Rect bounds) {
super.setBounds(bounds);
}
@Override
public void draw(@NonNull Canvas canvas) {
canvas.save();
canvas.scale(0.5f, 0.5f);
fallbackContactPhotoDrawable.draw(canvas);
canvas.restore();
}
@Override
public void setAlpha(int alpha) {
}
@Override
public void setColorFilter(@Nullable ColorFilter colorFilter) {
}
@Override
public int getOpacity() {
return PixelFormat.OPAQUE;
}
}
}

View File

@@ -43,8 +43,8 @@ import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.StickyHeaderDecoration.StickyHeaderAdapter;
import org.thoughtcrime.securesms.util.Util;
import java.util.HashSet;
import java.util.Set;
import java.util.List;
import java.util.Locale;
/**
* List adapter to display all contacts and their related information
@@ -70,7 +70,26 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
private final ItemClickListener clickListener;
private final GlideRequests glideRequests;
private final Set<SelectedContact> selectedContacts = new HashSet<>();
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 {
@@ -227,8 +246,8 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
return getHeaderString(position);
}
public Set<SelectedContact> getSelectedContacts() {
return selectedContacts;
public List<SelectedContact> getSelectedContacts() {
return selectedContacts.getContacts();
}
private CharSequence getSpannedHeaderString(int position) {

View File

@@ -35,6 +35,7 @@ public class ContactSelectionListItem extends LinearLayout implements RecipientF
private CheckBox checkBox;
private String number;
private String chipName;
private int contactType;
private LiveRecipient recipient;
private GlideRequests glideRequests;
@@ -128,8 +129,10 @@ public class ContactSelectionListItem extends LinearLayout implements RecipientF
if (recipient != null) {
this.nameView.setText(recipient);
chipName = recipient.getShortDisplayName(getContext());
} else {
this.nameView.setText(name);
chipName = name;
}
}
@@ -137,6 +140,14 @@ public class ContactSelectionListItem extends LinearLayout implements RecipientF
return number;
}
public String getChipName() {
return chipName;
}
public @Nullable LiveRecipient getRecipient() {
return recipient;
}
public boolean isUsernameType() {
return contactType == ContactRepository.NEW_USERNAME_TYPE;
}

View File

@@ -8,16 +8,12 @@ import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import java.util.Objects;
/**
* Model for a contact and the various ways it could be represented. Used in situations where we
* don't want to create Recipients for the wrapped data (like a custom-entered phone number for
* someone you don't yet have a conversation with).
*
* Designed so that two instances will be equal if *any* of its properties match.
*/
public class SelectedContact {
public final class SelectedContact {
private final RecipientId recipientId;
private final String number;
private final String username;
@@ -46,19 +42,14 @@ public class SelectedContact {
}
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
SelectedContact that = (SelectedContact) o;
/**
* Returns true iff any non-null property matches one on the other contact.
*/
public boolean matches(@Nullable SelectedContact other) {
if (other == null) return false;
return Objects.equals(recipientId, that.recipientId) ||
Objects.equals(number, that.number) ||
Objects.equals(username, that.username);
}
@Override
public int hashCode() {
return Objects.hash(recipientId, number, username);
return recipientId != null && recipientId.equals(other.recipientId) ||
number != null && number .equals(other.number) ||
username != null && username .equals(other.username);
}
}

View File

@@ -0,0 +1,58 @@
package org.thoughtcrime.securesms.contacts;
import androidx.annotation.NonNull;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
/**
* Specialised set for {@link SelectedContact} that will not allow more than one entry that
* {@link SelectedContact#matches(SelectedContact)} any other.
*/
public final class SelectedContactSet {
private final List<SelectedContact> contacts = new LinkedList<>();
public boolean add(@NonNull SelectedContact contact) {
if (contains(contact)) {
return false;
}
contacts.add(contact);
return true;
}
public boolean contains(@NonNull SelectedContact otherContact) {
for (SelectedContact contact : contacts) {
if (otherContact.matches(contact)) {
return true;
}
}
return false;
}
public List<SelectedContact> getContacts() {
return new ArrayList<>(contacts);
}
public void clear() {
contacts.clear();
}
public int remove(@NonNull SelectedContact otherContact) {
int removeCount = 0;
Iterator<SelectedContact> iterator = contacts.iterator();
while (iterator.hasNext()) {
SelectedContact next = iterator.next();
if (next.matches(otherContact)) {
iterator.remove();
removeCount++;
}
}
return removeCount;
}
}

View File

@@ -415,6 +415,12 @@ public class Recipient {
context.getString(R.string.Recipient_unknown));
}
public @NonNull String getShortDisplayName(@NonNull Context context) {
return Util.getFirstNonEmpty(getName(context),
getProfileName().getGivenName(),
getDisplayName(context));
}
public @NonNull MaterialColor getColor() {
if (isGroupInternal()) {
return MaterialColor.GROUP;
@@ -610,6 +616,10 @@ public class Recipient {
return getFallbackContactPhotoDrawable(context, inverted, DEFAULT_FALLBACK_PHOTO_PROVIDER);
}
public @NonNull Drawable getSmallFallbackContactPhotoDrawable(Context context, boolean inverted) {
return getSmallFallbackContactPhotoDrawable(context, inverted, DEFAULT_FALLBACK_PHOTO_PROVIDER);
}
public @NonNull Drawable getFallbackContactPhotoDrawable(Context context, boolean inverted, @Nullable FallbackPhotoProvider fallbackPhotoProvider) {
return getFallbackContactPhoto(Util.firstNonNull(fallbackPhotoProvider, DEFAULT_FALLBACK_PHOTO_PROVIDER)).asDrawable(context, getColor().toAvatarColor(context), inverted);
}