diff --git a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java index 6f521ebd99..4acd5d6e06 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java @@ -29,6 +29,7 @@ import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.WindowManager; +import android.view.animation.CycleInterpolator; import android.widget.Button; import android.widget.HorizontalScrollView; import android.widget.TextView; @@ -67,7 +68,6 @@ import org.thoughtcrime.securesms.permissions.Permissions; 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; @@ -81,6 +81,7 @@ import org.whispersystems.libsignal.util.guava.Optional; import java.io.IOException; import java.util.Collections; import java.util.List; +import java.util.Locale; /** * Fragment for selecting a one or more contacts from a list. @@ -94,13 +95,16 @@ public final class ContactSelectionListFragment extends Fragment @SuppressWarnings("unused") private static final String TAG = Log.tag(ContactSelectionListFragment.class); - private static final int CHIP_GROUP_EMPTY_COUNT = 1; + private static final int CHIP_GROUP_EMPTY_CHILD_COUNT = 1; private static final int CHIP_GROUP_REVEAL_DURATION_MS = 150; - public static final String DISPLAY_MODE = "display_mode"; - public static final String MULTI_SELECT = "multi_select"; - public static final String REFRESHABLE = "refreshable"; - public static final String RECENTS = "recents"; + public static final int NO_LIMIT = Integer.MAX_VALUE; + + public static final String DISPLAY_MODE = "display_mode"; + public static final String MULTI_SELECT = "multi_select"; + public static final String REFRESHABLE = "refreshable"; + public static final String RECENTS = "recents"; + public static final String SELECTION_LIMIT = "selection_limit"; private ConstraintLayout constraintLayout; private TextView emptyText; @@ -116,12 +120,14 @@ public final class ContactSelectionListFragment extends Fragment private ContactSelectionListAdapter cursorRecyclerViewAdapter; private ChipGroup chipGroup; private HorizontalScrollView chipGroupScrollContainer; + private TextView groupLimit; @Nullable private FixedViewsAdapter headerAdapter; @Nullable private FixedViewsAdapter footerAdapter; @Nullable private ListCallback listCallback; @Nullable private ScrollCallback scrollCallback; private GlideRequests glideRequests; + private int selectionLimit; @Override public void onAttach(@NonNull Context context) { @@ -185,15 +191,29 @@ public final class ContactSelectionListFragment extends Fragment showContactsProgress = view.findViewById(R.id.progress); chipGroup = view.findViewById(R.id.chipGroup); chipGroupScrollContainer = view.findViewById(R.id.chipGroupScrollContainer); + groupLimit = view.findViewById(R.id.group_limit); constraintLayout = view.findViewById(R.id.container); recyclerView.setLayoutManager(new LinearLayoutManager(getActivity())); swipeRefresh.setEnabled(requireActivity().getIntent().getBooleanExtra(REFRESHABLE, true)); + selectionLimit = requireActivity().getIntent().getIntExtra(SELECTION_LIMIT, NO_LIMIT); + + updateGroupLimit(getChipCount()); + return view; } + private void updateGroupLimit(int childCount) { + if (selectionLimit != NO_LIMIT) { + groupLimit.setText(String.format(Locale.getDefault(), "%d/%d", childCount, selectionLimit)); + groupLimit.setVisibility(View.VISIBLE); + } else { + groupLimit.setVisibility(View.GONE); + } + } + @Override public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); @@ -408,6 +428,12 @@ public final class ContactSelectionListFragment extends Fragment : SelectedContact.forPhone(contact.getRecipientId().orNull(), contact.getNumber()); if (!isMulti() || !cursorRecyclerViewAdapter.isSelectedContact(selectedContact)) { + if (selectionLimitReached()) { + Toast.makeText(requireContext(), R.string.ContactSelectionListFragment_the_group_is_full, Toast.LENGTH_SHORT).show(); + groupLimit.animate().scaleX(1.3f).scaleY(1.3f).setInterpolator(new CycleInterpolator(0.5f)).start(); + return; + } + if (contact.isUsernameType()) { AlertDialog loadingDialog = SimpleProgressDialog.show(requireContext()); @@ -447,6 +473,10 @@ public final class ContactSelectionListFragment extends Fragment }} } + private boolean selectionLimitReached() { + return getChipCount() >= selectionLimit; + } + private void markContactSelected(@NonNull SelectedContact selectedContact, @NonNull ContactSelectionListItem listItem) { cursorRecyclerViewAdapter.addSelectedContact(selectedContact); listItem.setChecked(true); @@ -469,7 +499,9 @@ public final class ContactSelectionListFragment extends Fragment } } - if (chipGroup.getChildCount() == CHIP_GROUP_EMPTY_COUNT) { + updateGroupLimit(getChipCount()); + + if (getChipCount() == 0) { setChipGroupVisibility(ConstraintSet.GONE); } } @@ -477,7 +509,7 @@ public final class ContactSelectionListFragment extends Fragment private void addChipForContact(@NonNull ContactSelectionListItem contact, @NonNull SelectedContact selectedContact) { final ContactChip chip = new ContactChip(requireContext()); - if (chipGroup.getChildCount() == CHIP_GROUP_EMPTY_COUNT) { + if (getChipCount() == 0) { setChipGroupVisibility(ConstraintSet.VISIBLE); } @@ -503,12 +535,23 @@ public final class ContactSelectionListFragment extends Fragment LiveRecipient recipient = contact.getRecipient(); if (recipient != null) { - chip.setAvatar(glideRequests, recipient.get(), () -> chipGroup.addView(chip)); + chip.setAvatar(glideRequests, recipient.get(), () -> addChip(chip)); } else { - chipGroup.addView(chip); + addChip(chip); } } + private void addChip(@NonNull ContactChip chip) { + chipGroup.addView(chip); + updateGroupLimit(getChipCount()); + } + + private int getChipCount() { + int count = chipGroup.getChildCount() - CHIP_GROUP_EMPTY_CHILD_COUNT; + if (count < 0) throw new AssertionError(); + return count; + } + private void registerChipRecipientObserver(@NonNull ContactChip chip, @Nullable LiveRecipient recipient) { if (recipient != null) { recipient.observe(getViewLifecycleOwner(), resolved -> { 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 1953d7c550..0a6c62c538 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 @@ -44,6 +44,7 @@ public class CreateGroupActivity extends ContactSelectionActivity { : ContactsCursorLoader.DisplayMode.FLAG_PUSH; intent.putExtra(ContactSelectionListFragment.DISPLAY_MODE, displayMode); + intent.putExtra(ContactSelectionListFragment.SELECTION_LIMIT, FeatureFlags.gv2GroupCapacity() - 1); return intent; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupFragment.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupFragment.java index 5a69d1c1ce..5f05a3ecbc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupFragment.java @@ -23,14 +23,12 @@ import androidx.fragment.app.FragmentActivity; import androidx.lifecycle.ViewModelProviders; import org.thoughtcrime.securesms.AvatarPreviewActivity; -import org.thoughtcrime.securesms.ContactSelectionListFragment; import org.thoughtcrime.securesms.MediaPreviewActivity; import org.thoughtcrime.securesms.MuteDialog; import org.thoughtcrime.securesms.PushContactSelectionActivity; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.AvatarImageView; import org.thoughtcrime.securesms.components.ThreadPhotoRailView; -import org.thoughtcrime.securesms.contacts.ContactsCursorLoader; import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto; import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto; import org.thoughtcrime.securesms.groups.GroupId; @@ -225,11 +223,7 @@ public class ManageGroupFragment extends Fragment { disappearingMessagesRow.setOnClickListener(v -> viewModel.handleExpirationSelection()); blockGroup.setOnClickListener(v -> viewModel.blockAndLeave(requireActivity())); - addMembers.setOnClickListener(v -> { - Intent intent = new Intent(requireActivity(), PushContactSelectionActivity.class); - intent.putExtra(ContactSelectionListFragment.DISPLAY_MODE, ContactsCursorLoader.DisplayMode.FLAG_PUSH); - startActivityForResult(intent, PICK_CONTACT); - }); + addMembers.setOnClickListener(v -> viewModel.onAddMembersClick(this, PICK_CONTACT)); viewModel.getMembershipRights().observe(getViewLifecycleOwner(), r -> { if (r != null) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupRepository.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupRepository.java index ce3cc08a82..f1ad51ac54 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupRepository.java @@ -6,7 +6,10 @@ import androidx.annotation.NonNull; import androidx.annotation.WorkerThread; import androidx.core.util.Consumer; +import org.signal.storageservice.protos.groups.local.DecryptedGroup; +import org.thoughtcrime.securesms.ContactSelectionListFragment; import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.groups.GroupAccessControl; import org.thoughtcrime.securesms.groups.GroupChangeBusyException; @@ -21,6 +24,7 @@ import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; import org.thoughtcrime.securesms.util.concurrent.SimpleTask; @@ -47,6 +51,18 @@ final class ManageGroupRepository { SignalExecutors.BOUNDED.execute(() -> onGroupStateLoaded.accept(getGroupState())); } + void getGroupCapacity(@NonNull Consumer onGroupCapacityLoaded) { + SimpleTask.run(SignalExecutors.BOUNDED, () -> { + GroupDatabase.GroupRecord groupRecord = DatabaseFactory.getGroupDatabase(context).getGroup(groupId).get(); + if (groupRecord.isV2Group()) { + DecryptedGroup decryptedGroup = groupRecord.requireV2GroupProperties().getDecryptedGroup(); + return new GroupCapacityResult(decryptedGroup.getMembersCount(), decryptedGroup.getPendingMembersCount(), FeatureFlags.gv2GroupCapacity()); + } else { + return new GroupCapacityResult(groupRecord.getMembers().size(), 0, ContactSelectionListFragment.NO_LIMIT); + } + }, onGroupCapacityLoaded::accept); + } + @WorkerThread private GroupStateResult getGroupState() { ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context); @@ -152,4 +168,20 @@ final class ManageGroupRepository { } } + static final class GroupCapacityResult { + private final int fullMembers; + private final int pendingMembers; + private final int totalCapacity; + + GroupCapacityResult(int fullMembers, int pendingMembers, int totalCapacity) { + this.fullMembers = fullMembers; + this.pendingMembers = pendingMembers; + this.totalCapacity = totalCapacity; + } + + public int getRemainingCapacity() { + return totalCapacity - fullMembers - pendingMembers; + } + } + } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupViewModel.java index 2fb1d7e5dd..89969ce51a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupViewModel.java @@ -1,11 +1,13 @@ package org.thoughtcrime.securesms.groups.ui.managegroup; import android.content.Context; +import android.content.Intent; import android.database.Cursor; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.WorkerThread; +import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; @@ -14,7 +16,11 @@ import androidx.lifecycle.ViewModel; import androidx.lifecycle.ViewModelProvider; import org.thoughtcrime.securesms.BlockUnblockDialog; +import org.thoughtcrime.securesms.ContactSelectionListFragment; import org.thoughtcrime.securesms.ExpirationDialog; +import org.thoughtcrime.securesms.PushContactSelectionActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.contacts.ContactsCursorLoader; import org.thoughtcrime.securesms.database.MediaDatabase; import org.thoughtcrime.securesms.database.loaders.MediaLoader; import org.thoughtcrime.securesms.database.loaders.ThreadMediaLoader; @@ -211,6 +217,20 @@ public class ManageGroupViewModel extends ViewModel { Util.runOnMain(() -> Toast.makeText(context, GroupErrors.getUserDisplayMessage(e), Toast.LENGTH_LONG).show()); } + public void onAddMembersClick(@NonNull Fragment fragment, int resultCode) { + manageGroupRepository.getGroupCapacity(capacity -> { + int remainingCapacity = capacity.getRemainingCapacity(); + if (remainingCapacity <= 0) { + Toast.makeText(fragment.requireContext(), R.string.ContactSelectionListFragment_the_group_is_full, Toast.LENGTH_SHORT).show(); + } else { + Intent intent = new Intent(fragment.requireActivity(), PushContactSelectionActivity.class); + intent.putExtra(ContactSelectionListFragment.DISPLAY_MODE, ContactsCursorLoader.DisplayMode.FLAG_PUSH); + intent.putExtra(ContactSelectionListFragment.SELECTION_LIMIT, remainingCapacity); + fragment.startActivityForResult(intent, resultCode); + } + }); + } + static final class GroupViewState { private final long threadId; @NonNull private final Recipient groupRecipient; diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java index 0c46134149..f206cc93fa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -65,6 +65,7 @@ public final class FeatureFlags { private static final String VERSIONED_PROFILES = "android.versionedProfiles"; private static final String GROUPS_V2 = "android.groupsv2"; private static final String GROUPS_V2_CREATE = "android.groupsv2.create"; + private static final String GROUPS_V2_CAPACITY = "android.groupsv2.capacity"; /** * We will only store remote values for flags in this set. If you want a flag to be controllable @@ -87,6 +88,7 @@ public final class FeatureFlags { VERSIONED_PROFILES, GROUPS_V2, GROUPS_V2_CREATE, + GROUPS_V2_CAPACITY, NEW_GROUP_UI ); @@ -283,6 +285,13 @@ public final class FeatureFlags { return groupsV2() && getBoolean(GROUPS_V2_CREATE, false); } + /** + * Maximum number of members allowed in a group. + */ + public static int gv2GroupCapacity() { + return getInteger(GROUPS_V2_CAPACITY, 100); + } + /** Only for rendering debug info. */ public static synchronized @NonNull Map getMemoryValues() { return new TreeMap<>(REMOTE_VALUES); 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 8809ec68b9..2aae3b3310 100644 --- a/app/src/main/res/layout/contact_selection_list_fragment.xml +++ b/app/src/main/res/layout/contact_selection_list_fragment.xml @@ -110,6 +110,17 @@ + + Username not found "%1$s" is not a Signal user. Please check the username and try again. Okay + The group is full No blocked contacts