diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ab96f79f4..6b644d755 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -492,6 +492,9 @@ + + diff --git a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java index 24b6e4592..948236a4b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java @@ -51,6 +51,8 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; import androidx.transition.AutoTransition; import androidx.transition.TransitionManager; +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; import com.google.android.material.chip.ChipGroup; import com.pnikosis.materialishprogress.ProgressWheel; @@ -83,6 +85,7 @@ import java.io.IOException; import java.util.Collections; import java.util.List; import java.util.Locale; +import java.util.Set; /** * Fragment for selecting a one or more contacts from a list. @@ -101,11 +104,12 @@ public final class ContactSelectionListFragment extends Fragment 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"; + 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 TOTAL_CAPACITY = "total_capacity"; + public static final String CURRENT_SELECTION = "current_selection"; private ConstraintLayout constraintLayout; private TextView emptyText; @@ -129,6 +133,7 @@ public final class ContactSelectionListFragment extends Fragment @Nullable private ScrollCallback scrollCallback; private GlideRequests glideRequests; private int selectionLimit; + private Set currentSelection; @Override public void onAttach(@NonNull Context context) { @@ -205,16 +210,17 @@ public final class ContactSelectionListFragment extends Fragment swipeRefresh.setEnabled(requireActivity().getIntent().getBooleanExtra(REFRESHABLE, true)); - selectionLimit = requireActivity().getIntent().getIntExtra(SELECTION_LIMIT, NO_LIMIT); + selectionLimit = requireActivity().getIntent().getIntExtra(TOTAL_CAPACITY, NO_LIMIT); + currentSelection = getCurrentSelection(); updateGroupLimit(getChipCount()); return view; } - private void updateGroupLimit(int childCount) { + private void updateGroupLimit(int chipCount) { if (selectionLimit != NO_LIMIT) { - groupLimit.setText(String.format(Locale.getDefault(), "%d/%d", childCount, selectionLimit)); + groupLimit.setText(String.format(Locale.getDefault(), "%d/%d", currentSelection.size() + chipCount, selectionLimit)); groupLimit.setVisibility(View.VISIBLE); } else { groupLimit.setVisibility(View.GONE); @@ -242,6 +248,13 @@ public final class ContactSelectionListFragment extends Fragment return cursorRecyclerViewAdapter.getSelectedContactsCount(); } + private Set getCurrentSelection() { + List currentSelection = requireActivity().getIntent().getParcelableArrayListExtra(CURRENT_SELECTION); + + return currentSelection == null ? Collections.emptySet() + : Collections.unmodifiableSet(Stream.of(currentSelection).collect(Collectors.toSet())); + } + private boolean isMulti() { return requireActivity().getIntent().getBooleanExtra(MULTI_SELECT, false); } @@ -253,7 +266,8 @@ public final class ContactSelectionListFragment extends Fragment glideRequests, null, new ListClickListener(), - isMulti()); + isMulti(), + currentSelection); RecyclerViewConcatenateAdapterStickyHeader concatenateAdapter = new RecyclerViewConcatenateAdapterStickyHeader(); @@ -546,7 +560,13 @@ public final class ContactSelectionListFragment extends Fragment chip.setText(recipient.getShortDisplayName(requireContext())); chip.setContact(selectedContact); chip.setCloseIconVisible(true); - chip.setOnCloseIconClickListener(view -> markContactUnselected(selectedContact)); + chip.setOnCloseIconClickListener(view -> { + markContactUnselected(selectedContact); + + if (onContactSelectedListener != null) { + onContactSelectedListener.onContactDeselected(Optional.of(recipient.getId()), recipient.getE164().orNull()); + } + }); chipGroup.getLayoutTransition().addTransitionListener(new LayoutTransition.TransitionListener() { @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/PushContactSelectionActivity.java b/app/src/main/java/org/thoughtcrime/securesms/PushContactSelectionActivity.java index bda0009ee..2f3ce6102 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/PushContactSelectionActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/PushContactSelectionActivity.java @@ -45,16 +45,24 @@ public class PushContactSelectionActivity extends ContactSelectionActivity { getIntent().putExtra(ContactSelectionListFragment.MULTI_SELECT, true); super.onCreate(icicle, ready); + initializeToolbar(); + } + + protected void initializeToolbar() { getToolbar().setNavigationIcon(R.drawable.ic_check_24); getToolbar().setNavigationOnClickListener(v -> { - Intent resultIntent = getIntent(); - List selectedContacts = contactsFragment.getSelectedContacts(); - List recipients = Stream.of(selectedContacts).map(sc -> sc.getOrCreateRecipientId(this)).toList(); - - resultIntent.putParcelableArrayListExtra(KEY_SELECTED_RECIPIENTS, new ArrayList<>(recipients)); - - setResult(RESULT_OK, resultIntent); - finish(); + onFinishedSelection(); }); } + + protected final void onFinishedSelection() { + Intent resultIntent = getIntent(); + List selectedContacts = contactsFragment.getSelectedContacts(); + List recipients = Stream.of(selectedContacts).map(sc -> sc.getOrCreateRecipientId(this)).toList(); + + resultIntent.putParcelableArrayListExtra(KEY_SELECTED_RECIPIENTS, new ArrayList<>(recipients)); + + setResult(RESULT_OK, resultIntent); + finish(); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.java index 4e4a3361a..78dcdcd29 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.java @@ -45,6 +45,7 @@ import org.thoughtcrime.securesms.util.Util; import java.util.List; import java.util.Locale; +import java.util.Set; /** * List adapter to display all contacts and their related information @@ -67,10 +68,11 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter currentContacts; private final SelectedContactSet selectedContacts = new SelectedContactSet(); @@ -102,6 +104,7 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter currentContacts) { super(context, cursor); - this.li = LayoutInflater.from(context); - this.glideRequests = glideRequests; - this.drawables = context.obtainStyledAttributes(STYLE_ATTRIBUTES); - this.multiSelect = multiSelect; - this.clickListener = clickListener; + this.layoutInflater = LayoutInflater.from(context); + this.glideRequests = glideRequests; + this.drawables = context.obtainStyledAttributes(STYLE_ATTRIBUTES); + this.multiSelect = multiSelect; + this.clickListener = clickListener; + this.currentContacts = currentContacts; } @Override @@ -188,9 +201,9 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter members = groupRecord.getMembers(); - byte[] avatar = Util.readFully(AvatarHelper.getAvatar(context, groupRecord.getRecipientId())); + byte[] avatar = groupRecord.hasAvatar() ? Util.readFully(AvatarHelper.getAvatar(context, groupRecord.getRecipientId())) : null; Set addresses = new HashSet<>(members); addresses.addAll(newMembers); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupProtoUtil.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupProtoUtil.java index df354e8c9..69fa098f9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupProtoUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupProtoUtil.java @@ -17,6 +17,7 @@ import org.signal.zkgroup.groups.GroupMasterKey; import org.signal.zkgroup.util.UUIDUtil; import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context; import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations; import org.whispersystems.signalservice.api.util.UuidUtil; import org.whispersystems.signalservice.internal.push.SignalServiceProtos; @@ -88,6 +89,18 @@ public final class GroupProtoUtil { return Recipient.externalPush(context, uuid, null); } + @WorkerThread + public static @NonNull RecipientId uuidByteStringToRecipientId(@NonNull ByteString uuidByteString) { + UUID uuid = UUIDUtil.deserialize(uuidByteString.toByteArray()); + + if (uuid.equals(GroupsV2Operations.UNKNOWN_UUID)) { + return RecipientId.UNKNOWN; + } + + return RecipientId.from(uuid, null); + } + + public static boolean isMember(@NonNull UUID uuid, @NonNull List membersList) { ByteString uuidBytes = UuidUtil.toByteString(uuid); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/AddMembersResultCallback.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/AddMembersResultCallback.java new file mode 100644 index 000000000..d5bc85133 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/AddMembersResultCallback.java @@ -0,0 +1,5 @@ +package org.thoughtcrime.securesms.groups.ui; + +public interface AddMembersResultCallback { + void onMembersAdded(int numberOfMembersAdded); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersActivity.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersActivity.java new file mode 100644 index 000000000..c7fd39df1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersActivity.java @@ -0,0 +1,115 @@ +package org.thoughtcrime.securesms.groups.ui.addmembers; + +import android.content.Context; +import android.os.Bundle; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.lifecycle.ViewModelProviders; + +import org.thoughtcrime.securesms.ContactSelectionActivity; +import org.thoughtcrime.securesms.PushContactSelectionActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.util.guava.Optional; + +public class AddMembersActivity extends PushContactSelectionActivity { + + public static final String GROUP_ID = "group_id"; + + private View done; + private AlertDialog alert; + private AddMembersViewModel viewModel; + + @Override + protected void onCreate(Bundle icicle, boolean ready) { + getIntent().putExtra(ContactSelectionActivity.EXTRA_LAYOUT_RES_ID, R.layout.add_members_activity); + super.onCreate(icicle, ready); + + AddMembersViewModel.Factory factory = new AddMembersViewModel.Factory(getGroupId()); + + done = findViewById(R.id.done); + alert = buildConfirmationAlertDialog(); + viewModel = ViewModelProviders.of(this, factory) + .get(AddMembersViewModel.class); + + viewModel.getAddMemberDialogState().observe(this, state -> AddMembersActivity.updateAlertMessage(alert, state)); + + //noinspection CodeBlock2Expr + done.setOnClickListener(v -> { + viewModel.setDialogStateForSelectedContacts(contactsFragment.getSelectedContacts()); + alert.show(); + }); + + disableDone(); + } + + @Override + protected void initializeToolbar() { + getToolbar().setNavigationIcon(R.drawable.ic_arrow_left_24); + getToolbar().setNavigationOnClickListener(v -> { + setResult(RESULT_CANCELED); + finish(); + }); + } + + @Override + public void onContactSelected(Optional recipientId, String number) { + if (contactsFragment.hasQueryFilter()) { + getToolbar().clear(); + } + + if (contactsFragment.getSelectedContactsCount() >= 1) { + enableDone(); + } + } + + @Override + public void onContactDeselected(Optional recipientId, String number) { + if (contactsFragment.hasQueryFilter()) { + getToolbar().clear(); + } + + if (contactsFragment.getSelectedContactsCount() < 1) { + disableDone(); + } + } + + private void enableDone() { + done.setEnabled(true); + done.animate().alpha(1f); + } + + private void disableDone() { + done.setEnabled(false); + done.animate().alpha(0.5f); + } + + private GroupId getGroupId() { + return GroupId.parseOrThrow(getIntent().getStringExtra(GROUP_ID)); + } + + private AlertDialog buildConfirmationAlertDialog() { + return new AlertDialog.Builder(this) + .setMessage(" ") + .setNegativeButton(android.R.string.cancel, (dialog, which) -> dialog.cancel()) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + dialog.dismiss(); + onFinishedSelection(); + }) + .setCancelable(true) + .create(); + } + + private static void updateAlertMessage(@NonNull AlertDialog alertDialog, @NonNull AddMembersViewModel.AddMemberDialogMessageState state) { + Context context = alertDialog.getContext(); + Recipient recipient = Util.firstNonNull(state.getRecipient(), Recipient.UNKNOWN); + + alertDialog.setMessage(context.getResources().getQuantityString(R.plurals.AddMembersActivity__add_d_members_to_s, state.getSelectionCount(), + recipient.getDisplayName(context), state.getGroupTitle(), state.getSelectionCount())); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersRepository.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersRepository.java new file mode 100644 index 000000000..dc46171fd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersRepository.java @@ -0,0 +1,24 @@ +package org.thoughtcrime.securesms.groups.ui.addmembers; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.core.util.Consumer; + +import org.thoughtcrime.securesms.contacts.SelectedContact; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; + +class AddMembersRepository { + + private final Context context; + + AddMembersRepository() { + this.context = ApplicationDependencies.getApplication(); + } + + void getOrCreateRecipientId(@NonNull SelectedContact selectedContact, @NonNull Consumer consumer) { + SignalExecutors.BOUNDED.execute(() -> consumer.accept(selectedContact.getOrCreateRecipientId(context))); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersViewModel.java new file mode 100644 index 000000000..33031577e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersViewModel.java @@ -0,0 +1,142 @@ +package org.thoughtcrime.securesms.groups.ui.addmembers; + +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Transformations; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.contacts.SelectedContact; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.LiveGroup; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.DefaultValueLiveData; +import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; +import org.whispersystems.libsignal.util.guava.Preconditions; + +import java.util.List; +import java.util.Objects; + +public final class AddMembersViewModel extends ViewModel { + + private final AddMembersRepository repository; + private final LiveData addMemberDialogState; + private final MutableLiveData partialState; + + private AddMembersViewModel(@NonNull GroupId groupId) { + repository = new AddMembersRepository(); + partialState = new MutableLiveData<>(); + addMemberDialogState = LiveDataUtil.combineLatest(Transformations.map(new LiveGroup(groupId).getTitle(), AddMembersViewModel::titleOrDefault), + Transformations.switchMap(partialState, AddMembersViewModel::getStateWithoutGroupTitle), + AddMembersViewModel::getStateWithGroupTitle); + } + + LiveData getAddMemberDialogState() { + return addMemberDialogState; + } + + void setDialogStateForSelectedContacts(@NonNull List selectedContacts) { + if (selectedContacts.size() == 1) { + setDialogStateForSingleRecipient(selectedContacts.get(0)); + } else { + setDialogStateForMultipleRecipients(selectedContacts.size()); + } + } + + private void setDialogStateForSingleRecipient(@NonNull SelectedContact selectedContact) { + //noinspection CodeBlock2Expr + repository.getOrCreateRecipientId(selectedContact, recipientId -> { + partialState.postValue(new AddMemberDialogMessageStatePartial(recipientId)); + }); + } + + private void setDialogStateForMultipleRecipients(int recipientCount) { + partialState.setValue(new AddMemberDialogMessageStatePartial(recipientCount)); + } + + private static LiveData getStateWithoutGroupTitle(@NonNull AddMemberDialogMessageStatePartial partialState) { + if (partialState.recipientId != null) { + return Transformations.map(Recipient.live(partialState.recipientId).getLiveData(), r -> new AddMemberDialogMessageState(r, "")); + } else { + return new DefaultValueLiveData<>(new AddMemberDialogMessageState(partialState.memberCount, "")); + } + } + + private static AddMemberDialogMessageState getStateWithGroupTitle(@NonNull String title, @NonNull AddMemberDialogMessageState stateWithoutTitle) { + return new AddMemberDialogMessageState(stateWithoutTitle.recipient, stateWithoutTitle.selectionCount, title); + } + + private static @NonNull String titleOrDefault(@Nullable String title) { + return TextUtils.isEmpty(title) ? ApplicationDependencies.getApplication().getString(R.string.Recipient_unknown) + : Objects.requireNonNull(title); + } + + private static final class AddMemberDialogMessageStatePartial { + private final RecipientId recipientId; + private final int memberCount; + + private AddMemberDialogMessageStatePartial(@NonNull RecipientId recipientId) { + this.recipientId = recipientId; + this.memberCount = 1; + } + + private AddMemberDialogMessageStatePartial(int memberCount) { + Preconditions.checkArgument(memberCount > 1); + this.memberCount = memberCount; + this.recipientId = null; + } + } + + public static final class AddMemberDialogMessageState { + private final Recipient recipient; + private final String groupTitle; + private final int selectionCount; + + private AddMemberDialogMessageState(@NonNull Recipient recipient, @NonNull String groupTitle) { + this(recipient, 1, groupTitle); + } + + private AddMemberDialogMessageState(int selectionCount, @NonNull String groupTitle) { + this(null, selectionCount, groupTitle); + } + + private AddMemberDialogMessageState(@Nullable Recipient recipient, int selectionCount, @NonNull String groupTitle) { + this.recipient = recipient; + this.groupTitle = groupTitle; + this.selectionCount = selectionCount; + } + + public Recipient getRecipient() { + return recipient; + } + + public int getSelectionCount() { + return selectionCount; + } + + public @NonNull String getGroupTitle() { + return groupTitle; + } + } + + public static class Factory implements ViewModelProvider.Factory { + + private final GroupId groupId; + + public Factory(@NonNull GroupId groupId) { + this.groupId = groupId; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + return Objects.requireNonNull(modelClass.cast(new AddMembersViewModel(groupId))); + } + } +} 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 9a87e818b..14d0f8c80 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,7 +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); + intent.putExtra(ContactSelectionListFragment.TOTAL_CAPACITY, 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 4800a7c6b..6a5b8cfa7 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 @@ -19,8 +19,11 @@ import androidx.appcompat.widget.Toolbar; import androidx.core.view.ViewCompat; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; +import androidx.lifecycle.LifecycleObserver; import androidx.lifecycle.ViewModelProviders; +import com.google.android.material.snackbar.Snackbar; + import org.thoughtcrime.securesms.AvatarPreviewActivity; import org.thoughtcrime.securesms.MediaPreviewActivity; import org.thoughtcrime.securesms.MuteDialog; @@ -44,6 +47,7 @@ import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment; import org.thoughtcrime.securesms.util.DateUtils; +import org.thoughtcrime.securesms.util.LifecycleCursorWrapper; import org.thoughtcrime.securesms.util.ThemeUtil; import java.util.List; @@ -322,6 +326,8 @@ public class ManageGroupFragment extends Fragment { } else { customNotificationsRow.setVisibility(View.GONE); } + + viewModel.getSnackbarEvents().observe(getViewLifecycleOwner(), this::handleSnackbarEvent); } public boolean onMenuItemSelected(@NonNull MenuItem item) { @@ -349,6 +355,8 @@ public class ManageGroupFragment extends Fragment { if (context == null) return; if (this.cursorFactory != null) { Cursor cursor = this.cursorFactory.create(); + getViewLifecycleOwner().getLifecycle().addObserver(new LifecycleCursorWrapper(cursor)); + threadPhotoRailView.setCursor(GlideApp.with(context), cursor); groupMediaCard.setVisibility(cursor.getCount() > 0 ? View.VISIBLE : View.GONE); } else { @@ -357,6 +365,16 @@ public class ManageGroupFragment extends Fragment { } } + private void handleSnackbarEvent(@NonNull ManageGroupViewModel.SnackbarEvent snackbarEvent) { + Snackbar.make(requireView(), buildSnackbarString(snackbarEvent), Snackbar.LENGTH_SHORT).show(); + } + + private @NonNull String buildSnackbarString(@NonNull ManageGroupViewModel.SnackbarEvent snackbarEvent) { + return getResources().getQuantityString(R.plurals.ManageGroupActivity_added, + snackbarEvent.getNumberOfMembersAdded(), + snackbarEvent.getNumberOfMembersAdded()); + } + @Override public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { super.onActivityResult(requestCode, resultCode, data); 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 f1ad51ac5..28a64190c 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,11 @@ import androidx.annotation.NonNull; import androidx.annotation.WorkerThread; import androidx.core.util.Consumer; +import com.annimon.stream.Stream; +import com.google.protobuf.ByteString; + import org.signal.storageservice.protos.groups.local.DecryptedGroup; +import org.signal.zkgroup.util.UUIDUtil; import org.thoughtcrime.securesms.ContactSelectionListFragment; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.GroupDatabase; @@ -18,7 +22,9 @@ import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.groups.GroupInsufficientRightsException; import org.thoughtcrime.securesms.groups.GroupManager; import org.thoughtcrime.securesms.groups.GroupNotAMemberException; +import org.thoughtcrime.securesms.groups.GroupProtoUtil; import org.thoughtcrime.securesms.groups.MembershipNotSuitableForV2Exception; +import org.thoughtcrime.securesms.groups.ui.AddMembersResultCallback; import org.thoughtcrime.securesms.groups.ui.GroupChangeErrorCallback; import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason; import org.thoughtcrime.securesms.logging.Log; @@ -27,9 +33,12 @@ 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; +import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations; import java.io.IOException; +import java.util.LinkedList; import java.util.List; +import java.util.UUID; final class ManageGroupRepository { @@ -55,10 +64,17 @@ final class ManageGroupRepository { 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()); + DecryptedGroup decryptedGroup = groupRecord.requireV2GroupProperties().getDecryptedGroup(); + List pendingMembers = Stream.of(decryptedGroup.getPendingMembersList()) + .map(member -> GroupProtoUtil.uuidByteStringToRecipientId(member.getUuid())) + .toList(); + List members = new LinkedList<>(groupRecord.getMembers()); + + members.addAll(pendingMembers); + + return new GroupCapacityResult(members, FeatureFlags.gv2GroupCapacity()); } else { - return new GroupCapacityResult(groupRecord.getMembers().size(), 0, ContactSelectionListFragment.NO_LIMIT); + return new GroupCapacityResult(groupRecord.getMembers(), ContactSelectionListFragment.NO_LIMIT); } }, onGroupCapacityLoaded::accept); } @@ -130,10 +146,11 @@ final class ManageGroupRepository { }); } - void addMembers(@NonNull List selected, @NonNull GroupChangeErrorCallback error) { + void addMembers(@NonNull List selected, @NonNull AddMembersResultCallback addMembersResultCallback, @NonNull GroupChangeErrorCallback error) { SignalExecutors.UNBOUNDED.execute(() -> { try { GroupManager.addMembers(context, groupId, selected); + addMembersResultCallback.onMembersAdded(selected.size()); } catch (GroupInsufficientRightsException | GroupNotAMemberException e) { Log.w(TAG, e); error.onError(GroupChangeFailureReason.NO_RIGHTS); @@ -169,18 +186,20 @@ final class ManageGroupRepository { } static final class GroupCapacityResult { - private final int fullMembers; - private final int pendingMembers; - private final int totalCapacity; + private final List members; + private final int totalCapacity; - GroupCapacityResult(int fullMembers, int pendingMembers, int totalCapacity) { - this.fullMembers = fullMembers; - this.pendingMembers = pendingMembers; + GroupCapacityResult(@NonNull List members, int totalCapacity) { + this.members = members; this.totalCapacity = totalCapacity; } - public int getRemainingCapacity() { - return totalCapacity - fullMembers - pendingMembers; + public @NonNull List getMembers() { + return members; + } + + public int getTotalCapacity() { + return totalCapacity; } } 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 89969ce51..df96cc486 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 @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.groups.ui.managegroup; import android.content.Context; import android.content.Intent; import android.database.Cursor; +import android.text.TextUtils; import android.widget.Toast; import androidx.annotation.NonNull; @@ -18,7 +19,6 @@ 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; @@ -30,14 +30,17 @@ import org.thoughtcrime.securesms.groups.LiveGroup; import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason; import org.thoughtcrime.securesms.groups.ui.GroupErrors; import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry; +import org.thoughtcrime.securesms.groups.ui.addmembers.AddMembersActivity; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.RecipientUtil; import org.thoughtcrime.securesms.util.DefaultValueLiveData; import org.thoughtcrime.securesms.util.ExpirationUtil; +import org.thoughtcrime.securesms.util.SingleLiveEvent; import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; +import java.util.ArrayList; import java.util.List; public class ManageGroupViewModel extends ViewModel { @@ -46,6 +49,7 @@ public class ManageGroupViewModel extends ViewModel { private final Context context; private final ManageGroupRepository manageGroupRepository; + private final SingleLiveEvent snackbarEvents = new SingleLiveEvent<>(); private final LiveData title; private final LiveData isAdmin; private final LiveData canEditGroupAttributes; @@ -72,7 +76,9 @@ public class ManageGroupViewModel extends ViewModel { LiveGroup liveGroup = new LiveGroup(manageGroupRepository.getGroupId()); - this.title = liveGroup.getTitle(); + this.title = Transformations.map(liveGroup.getTitle(), + title -> TextUtils.isEmpty(title) ? context.getString(R.string.Recipient_unknown) + : title); this.isAdmin = liveGroup.isSelfAdmin(); this.canCollapseMemberList = LiveDataUtil.combineLatest(memberListCollapseState, Transformations.map(liveGroup.getFullMembers(), m -> m.size() > MAX_COLLAPSED_MEMBERS), @@ -162,6 +168,10 @@ public class ManageGroupViewModel extends ViewModel { return hasCustomNotifications; } + SingleLiveEvent getSnackbarEvents() { + return snackbarEvents; + } + public LiveData getCanCollapseMemberList() { return canCollapseMemberList; } @@ -187,7 +197,7 @@ public class ManageGroupViewModel extends ViewModel { } void onAddMembers(List selected) { - manageGroupRepository.addMembers(selected, this::showErrorToast); + manageGroupRepository.addMembers(selected, this::showSuccessSnackbar, this::showErrorToast); } void setMuteUntil(long muteUntil) { @@ -212,6 +222,11 @@ public class ManageGroupViewModel extends ViewModel { } } + @WorkerThread + private void showSuccessSnackbar(int numberOfMembersAdded) { + snackbarEvents.postValue(new SnackbarEvent(numberOfMembersAdded)); + } + @WorkerThread private void showErrorToast(@NonNull GroupChangeFailureReason e) { Util.runOnMain(() -> Toast.makeText(context, GroupErrors.getUserDisplayMessage(e), Toast.LENGTH_LONG).show()); @@ -219,13 +234,15 @@ public class ManageGroupViewModel extends ViewModel { public void onAddMembersClick(@NonNull Fragment fragment, int resultCode) { manageGroupRepository.getGroupCapacity(capacity -> { - int remainingCapacity = capacity.getRemainingCapacity(); + int remainingCapacity = capacity.getTotalCapacity(); 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 intent = new Intent(fragment.requireActivity(), AddMembersActivity.class); + intent.putExtra(AddMembersActivity.GROUP_ID, manageGroupRepository.getGroupId().toString()); intent.putExtra(ContactSelectionListFragment.DISPLAY_MODE, ContactsCursorLoader.DisplayMode.FLAG_PUSH); - intent.putExtra(ContactSelectionListFragment.SELECTION_LIMIT, remainingCapacity); + intent.putExtra(ContactSelectionListFragment.TOTAL_CAPACITY, remainingCapacity); + intent.putParcelableArrayListExtra(ContactSelectionListFragment.CURRENT_SELECTION, new ArrayList<>(capacity.getMembers())); fragment.startActivityForResult(intent, resultCode); } }); @@ -276,6 +293,18 @@ public class ManageGroupViewModel extends ViewModel { } } + static final class SnackbarEvent { + private final int numberOfMembersAdded; + + private SnackbarEvent(int numberOfMembersAdded) { + this.numberOfMembersAdded = numberOfMembersAdded; + } + + public int getNumberOfMembersAdded() { + return numberOfMembersAdded; + } + } + private enum CollapseState { OPEN, COLLAPSED diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/LifecycleCursorWrapper.java b/app/src/main/java/org/thoughtcrime/securesms/util/LifecycleCursorWrapper.java new file mode 100644 index 000000000..3562476f8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/LifecycleCursorWrapper.java @@ -0,0 +1,27 @@ +package org.thoughtcrime.securesms.util; + +import android.database.Cursor; +import android.database.CursorWrapper; + +import androidx.annotation.NonNull; +import androidx.lifecycle.DefaultLifecycleObserver; +import androidx.lifecycle.Lifecycle; +import androidx.lifecycle.LifecycleObserver; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.OnLifecycleEvent; + +/** + * Wraps a {@link Cursor} that will be closed automatically when the {@link Lifecycle.Event}.ON_DESTROY + * is fired from the lifecycle this object is observing. + */ +public class LifecycleCursorWrapper extends CursorWrapper implements DefaultLifecycleObserver { + + public LifecycleCursorWrapper(Cursor cursor) { + super(cursor); + } + + @Override + public void onDestroy(@NonNull LifecycleOwner owner) { + close(); + } +} diff --git a/app/src/main/res/drawable/contact_selection_checkbox_dark.xml b/app/src/main/res/drawable/contact_selection_checkbox_dark.xml new file mode 100644 index 000000000..2f2b7f548 --- /dev/null +++ b/app/src/main/res/drawable/contact_selection_checkbox_dark.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/contact_selection_checkbox.xml b/app/src/main/res/drawable/contact_selection_checkbox_light.xml similarity index 57% rename from app/src/main/res/drawable/contact_selection_checkbox.xml rename to app/src/main/res/drawable/contact_selection_checkbox_light.xml index c1228f8cd..9a87f5d8f 100644 --- a/app/src/main/res/drawable/contact_selection_checkbox.xml +++ b/app/src/main/res/drawable/contact_selection_checkbox_light.xml @@ -1,5 +1,16 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/progress_button_state_dark.xml b/app/src/main/res/drawable/progress_button_state_dark.xml new file mode 100644 index 000000000..317f3b400 --- /dev/null +++ b/app/src/main/res/drawable/progress_button_state_dark.xml @@ -0,0 +1,11 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/progress_button_state.xml b/app/src/main/res/drawable/progress_button_state_light.xml similarity index 100% rename from app/src/main/res/drawable/progress_button_state.xml rename to app/src/main/res/drawable/progress_button_state_light.xml diff --git a/app/src/main/res/layout/add_group_details_fragment.xml b/app/src/main/res/layout/add_group_details_fragment.xml index 0a5a48a49..bbf6ca2c1 100644 --- a/app/src/main/res/layout/add_group_details_fragment.xml +++ b/app/src/main/res/layout/add_group_details_fragment.xml @@ -44,7 +44,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" - android:background="@color/core_ultramarine" + android:background="?colorAccent" android:paddingStart="16dp" android:paddingTop="8dp" android:paddingEnd="16dp" @@ -84,9 +84,9 @@ android:layout_marginBottom="16dp" android:textColor="@color/white" app:cpb_colorIndicator="@color/white" - app:cpb_colorProgress="@color/core_ultramarine" + app:cpb_colorProgress="?colorAccent" app:cpb_cornerRadius="28dp" - app:cpb_selectorIdle="@drawable/progress_button_state" + app:cpb_selectorIdle="?attr/circular_progress_button_state" app:cpb_textIdle="@string/AddGroupDetailsFragment__create" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" /> diff --git a/app/src/main/res/layout/add_members_activity.xml b/app/src/main/res/layout/add_members_activity.xml new file mode 100644 index 000000000..0072ab117 --- /dev/null +++ b/app/src/main/res/layout/add_members_activity.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + \ 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_selection_list_item.xml index 0101d5411..37d614740 100644 --- a/app/src/main/res/layout/contact_selection_list_item.xml +++ b/app/src/main/res/layout/contact_selection_list_item.xml @@ -27,7 +27,7 @@ diff --git a/app/src/main/res/layout/sticker_preview_activity.xml b/app/src/main/res/layout/sticker_preview_activity.xml index c9bcbb703..4b9a62a17 100644 --- a/app/src/main/res/layout/sticker_preview_activity.xml +++ b/app/src/main/res/layout/sticker_preview_activity.xml @@ -102,7 +102,7 @@ app:cpb_colorIndicator="@color/white" app:cpb_colorProgress="@color/StickerPreviewActivity_install_button_color" app:cpb_cornerRadius="4dp" - app:cpb_selectorIdle="@drawable/progress_button_state" + app:cpb_selectorIdle="@drawable/progress_button_state_light" app:cpb_textIdle="@string/StickerPackPreviewActivity_install" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="@id/sticker_install_list" diff --git a/app/src/main/res/layout/submit_debug_log_activity.xml b/app/src/main/res/layout/submit_debug_log_activity.xml index 9d0d4819e..c78c540f0 100644 --- a/app/src/main/res/layout/submit_debug_log_activity.xml +++ b/app/src/main/res/layout/submit_debug_log_activity.xml @@ -95,7 +95,7 @@ app:cpb_colorIndicator="@color/white" app:cpb_colorProgress="@color/core_ultramarine" app:cpb_cornerRadius="4dp" - app:cpb_selectorIdle="@drawable/progress_button_state" + app:cpb_selectorIdle="@drawable/progress_button_state_light" app:cpb_textIdle="@string/SubmitDebugLogActivity_submit" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" diff --git a/app/src/main/res/layout/username_edit_fragment.xml b/app/src/main/res/layout/username_edit_fragment.xml index 5916c2498..70d5dfaa9 100644 --- a/app/src/main/res/layout/username_edit_fragment.xml +++ b/app/src/main/res/layout/username_edit_fragment.xml @@ -55,7 +55,7 @@ app:cpb_colorIndicator="@color/white" app:cpb_colorProgress="@color/core_ultramarine" app:cpb_cornerRadius="4dp" - app:cpb_selectorIdle="@drawable/progress_button_state" + app:cpb_selectorIdle="@drawable/progress_button_state_light" app:cpb_textIdle="@string/UsernameEditFragment_submit" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index 353264472..fd3122dd9 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -158,6 +158,9 @@ + + + diff --git a/app/src/main/res/values/registration_styles.xml b/app/src/main/res/values/registration_styles.xml index 9ab06f8c9..9a780bd83 100644 --- a/app/src/main/res/values/registration_styles.xml +++ b/app/src/main/res/values/registration_styles.xml @@ -8,7 +8,7 @@ 4dp @color/white @color/core_ultramarine - @drawable/progress_button_state + @drawable/progress_button_state_light