diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f6dbdfedfd..dccf47a9cf 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -251,6 +251,10 @@ android:windowSoftInputMode="stateVisible" android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/> + + record = DatabaseFactory.getGroupDatabase(this).getGroup(getRecipient().getId()); + return record.isPresent() && record.get().isActive() && record.get().isV2Group(); + } + @SuppressWarnings("SimplifiableIfStatement") private boolean isSelfConversation() { if (!TextSecurePreferences.isPushRegistered(this)) return false; diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupProtoUtil.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupProtoUtil.java new file mode 100644 index 0000000000..e5af13b666 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupProtoUtil.java @@ -0,0 +1,37 @@ +package org.thoughtcrime.securesms.groups; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; + +import com.google.protobuf.ByteString; + +import org.signal.storageservice.protos.groups.local.DecryptedPendingMember; +import org.signal.zkgroup.util.UUIDUtil; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations; + +import java.util.UUID; + +public final class GroupProtoUtil { + + private GroupProtoUtil() { + } + + @WorkerThread + public static Recipient pendingMemberToRecipient(@NonNull Context context, @NonNull DecryptedPendingMember pendingMember) { + return uuidByteStringToRecipient(context, pendingMember.getUuid()); + } + + @WorkerThread + public static Recipient uuidByteStringToRecipient(@NonNull Context context, @NonNull ByteString uuidByteString) { + UUID uuid = UUIDUtil.deserialize(uuidByteString.toByteArray()); + + if (uuid.equals(GroupsV2Operations.UNKNOWN_UUID)) { + return Recipient.UNKNOWN; + } + + return Recipient.externalPush(context, uuid, null); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberEntry.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberEntry.java index e82c23d729..fd0ec948dd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberEntry.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberEntry.java @@ -20,7 +20,7 @@ public abstract class GroupMemberEntry { return onClick; } - public static class FullMember extends GroupMemberEntry { + public final static class FullMember extends GroupMemberEntry { private final Recipient member; @@ -32,4 +32,40 @@ public abstract class GroupMemberEntry { return member; } } + + public final static class PendingMember extends GroupMemberEntry { + private final Recipient invitee; + private final byte[] inviteeCipherText; + + public PendingMember(@NonNull Recipient invitee, @NonNull byte[] inviteeCipherText) { + this.invitee = invitee; + this.inviteeCipherText = inviteeCipherText; + } + + public Recipient getInvitee() { + return invitee; + } + + public byte[] getInviteeCipherText() { + return inviteeCipherText; + } + } + + public final static class UnknownPendingMemberCount extends GroupMemberEntry { + private Recipient inviter; + private int inviteCount; + + public UnknownPendingMemberCount(@NonNull Recipient inviter, int inviteCount) { + this.inviter = inviter; + this.inviteCount = inviteCount; + } + + public Recipient getInviter() { + return inviter; + } + + public int getInviteCount() { + return inviteCount; + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberListAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberListAdapter.java index f3631aa468..52ec5c3704 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberListAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberListAdapter.java @@ -18,7 +18,9 @@ import java.util.Collection; final class GroupMemberListAdapter extends RecyclerView.Adapter { - private static final int FULL_MEMBER = 0; + private static final int FULL_MEMBER = 0; + private static final int OWN_INVITE_PENDING = 1; + private static final int OTHER_INVITE_PENDING_COUNT = 2; private final ArrayList data = new ArrayList<>(); @@ -35,6 +37,14 @@ final class GroupMemberListAdapter extends RecyclerView.Adapter { + youInvited.setMembers(invitees); + youInvitedEmptyState.setVisibility(invitees.isEmpty() ? View.VISIBLE : View.GONE); + }); + + viewModel.getWhoOthersInvited().observe(getViewLifecycleOwner(), invitees -> { + othersInvited.setMembers(invitees); + othersInvitedEmptyState.setVisibility(invitees.isEmpty() ? View.VISIBLE : View.GONE); + }); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/pendingmemberinvites/PendingMemberInvitesViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/pendingmemberinvites/PendingMemberInvitesViewModel.java new file mode 100644 index 0000000000..1790544e52 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/pendingmemberinvites/PendingMemberInvitesViewModel.java @@ -0,0 +1,85 @@ +package org.thoughtcrime.securesms.groups.ui.pendingmemberinvites; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry; +import org.thoughtcrime.securesms.logging.Log; + +import java.util.ArrayList; +import java.util.List; + +public class PendingMemberInvitesViewModel extends ViewModel { + + private static final String TAG = Log.tag(PendingMemberInvitesViewModel.class); + + private final Context context; + private final GroupId groupId; + private final PendingMemberRepository pendingMemberRepository; + private final MutableLiveData> whoYouInvited = new MutableLiveData<>(); + private final MutableLiveData> whoOthersInvited = new MutableLiveData<>(); + + PendingMemberInvitesViewModel(@NonNull Context context, + @NonNull GroupId.V2 groupId, + @NonNull PendingMemberRepository pendingMemberRepository) + { + this.context = context; + this.groupId = groupId; + this.pendingMemberRepository = pendingMemberRepository; + + pendingMemberRepository.getInvitees(groupId, this::setMembers); + } + + public LiveData> getWhoYouInvited() { + return whoYouInvited; + } + + public LiveData> getWhoOthersInvited() { + return whoOthersInvited; + } + + private void setInvitees(List byYou, List byOthers) { + whoYouInvited.postValue(byYou); + whoOthersInvited.postValue(byOthers); + } + + private void setMembers(PendingMemberRepository.InviteeResult inviteeResult) { + List byMe = new ArrayList<>(inviteeResult.getByMe().size()); + List byOthers = new ArrayList<>(inviteeResult.getByOthers().size()); + + for (PendingMemberRepository.SinglePendingMemberInvitedByYou pendingMember : inviteeResult.getByMe()) { + byMe.add(new GroupMemberEntry.PendingMember(pendingMember.getInvitee(), + pendingMember.getInviteeCipherText())); + } + + for (PendingMemberRepository.MultiplePendingMembersInvitedByAnother pendingMembers : inviteeResult.getByOthers()) { + byOthers.add(new GroupMemberEntry.UnknownPendingMemberCount(pendingMembers.getInviter(), + pendingMembers.getUuidCipherTexts().size())); + } + + setInvitees(byMe, byOthers); + } + + public static class Factory implements ViewModelProvider.Factory { + + private final Context context; + private final GroupId.V2 groupId; + + public Factory(@NonNull Context context, @NonNull GroupId.V2 groupId) { + this.context = context; + this.groupId = groupId; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + //noinspection unchecked + return (T) new PendingMemberInvitesViewModel(context, groupId, new PendingMemberRepository(context.getApplicationContext())); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/pendingmemberinvites/PendingMemberRepository.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/pendingmemberinvites/PendingMemberRepository.java new file mode 100644 index 0000000000..741ab89240 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/pendingmemberinvites/PendingMemberRepository.java @@ -0,0 +1,131 @@ +package org.thoughtcrime.securesms.groups.ui.pendingmemberinvites; + +import android.content.Context; + +import androidx.annotation.NonNull; +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.storageservice.protos.groups.local.DecryptedPendingMember; +import org.signal.zkgroup.util.UUIDUtil; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.GroupProtoUtil; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Executor; + +final class PendingMemberRepository { + + private final Context context; + private final Executor executor; + + PendingMemberRepository(@NonNull Context context) { + this.context = context.getApplicationContext(); + this.executor = SignalExecutors.BOUNDED; + } + + public void getInvitees(GroupId.V2 groupId, @NonNull Consumer onInviteesLoaded) { + executor.execute(() -> { + GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); + GroupDatabase.V2GroupProperties v2GroupProperties = groupDatabase.getGroup(groupId).get().requireV2GroupProperties(); + DecryptedGroup decryptedGroup = v2GroupProperties.getDecryptedGroup(); + List pendingMembersList = decryptedGroup.getPendingMembersList(); + List byMe = new ArrayList<>(pendingMembersList.size()); + List byOthers = new ArrayList<>(pendingMembersList.size()); + ByteString self = ByteString.copyFrom(UUIDUtil.serialize(Recipient.self().getUuid().get())); + + Stream.of(pendingMembersList) + .groupBy(DecryptedPendingMember::getAddedByUuid) + .forEach(g -> + { + ByteString inviterUuid = g.getKey(); + List invitedMembers = g.getValue(); + + if (self.equals(inviterUuid)) { + for (DecryptedPendingMember pendingMember : invitedMembers) { + Recipient invitee = GroupProtoUtil.pendingMemberToRecipient(context, pendingMember); + byte[] uuidCipherText = pendingMember.getUuidCipherText().toByteArray(); + + byMe.add(new SinglePendingMemberInvitedByYou(invitee, uuidCipherText)); + } + } else { + Recipient inviter = GroupProtoUtil.uuidByteStringToRecipient(context, inviterUuid); + + ArrayList uuidCipherTexts = new ArrayList<>(invitedMembers.size()); + for (DecryptedPendingMember pendingMember : invitedMembers) { + uuidCipherTexts.add(pendingMember.getUuidCipherText().toByteArray()); + } + + byOthers.add(new MultiplePendingMembersInvitedByAnother(inviter, uuidCipherTexts)); + } + } + ); + + onInviteesLoaded.accept(new InviteeResult(byMe, byOthers)); + }); + } + + public static final class InviteeResult { + private final List byMe; + private final List byOthers; + + private InviteeResult(List byMe, + List byOthers) + { + this.byMe = byMe; + this.byOthers = byOthers; + } + + public List getByMe() { + return byMe; + } + + public List getByOthers() { + return byOthers; + } + } + + public final static class SinglePendingMemberInvitedByYou { + private final Recipient invitee; + private final byte[] inviteeCipherText; + + private SinglePendingMemberInvitedByYou(@NonNull Recipient invitee, @NonNull byte[] inviteeCipherText) { + this.invitee = invitee; + this.inviteeCipherText = inviteeCipherText; + } + + public Recipient getInvitee() { + return invitee; + } + + public byte[] getInviteeCipherText() { + return inviteeCipherText; + } + } + + public final static class MultiplePendingMembersInvitedByAnother { + private final Recipient inviter; + private final ArrayList uuidCipherTexts; + + private MultiplePendingMembersInvitedByAnother(@NonNull Recipient inviter, @NonNull ArrayList uuidCipherTexts) { + this.inviter = inviter; + this.uuidCipherTexts = uuidCipherTexts; + } + + public Recipient getInviter() { + return inviter; + } + + public ArrayList getUuidCipherTexts() { + return uuidCipherTexts; + } + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/group_pending_member_invites_activity.xml b/app/src/main/res/layout/group_pending_member_invites_activity.xml new file mode 100644 index 0000000000..2183c30315 --- /dev/null +++ b/app/src/main/res/layout/group_pending_member_invites_activity.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/layout/group_pending_member_invites_fragment.xml b/app/src/main/res/layout/group_pending_member_invites_fragment.xml new file mode 100644 index 0000000000..0ab7b72d97 --- /dev/null +++ b/app/src/main/res/layout/group_pending_member_invites_fragment.xml @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/selected_recipient_list_item.xml b/app/src/main/res/layout/selected_recipient_list_item.xml index 423d93def8..38e2d048fb 100644 --- a/app/src/main/res/layout/selected_recipient_list_item.xml +++ b/app/src/main/res/layout/selected_recipient_list_item.xml @@ -29,6 +29,7 @@ android:layout_alignParentEnd="true" android:layout_centerVertical="true" android:background="#00ffffff" + android:contentDescription="@string/GroupCreateActivity_remove_member_description" android:src="@drawable/ic_menu_remove_holo_light" /> \ No newline at end of file diff --git a/app/src/main/res/menu/conversation_push_group_v2_options.xml b/app/src/main/res/menu/conversation_push_group_v2_options.xml new file mode 100644 index 0000000000..0197b90f5d --- /dev/null +++ b/app/src/main/res/menu/conversation_push_group_v2_options.xml @@ -0,0 +1,16 @@ + + + + + + + + + + diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index c8f6e923db..1dd04ce93d 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -271,6 +271,9 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8799c79baf..7ffed60457 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -432,6 +432,7 @@ Couldn\'t add %1$s because they\'re not a Signal user. Loading group details… You\'re already in the group. + Remove member Share your profile name and photo with this group? @@ -441,6 +442,19 @@ You + + Pending group invites + People you invited + You have no pending invites. + Invites by other group members + No pending invites by other group members. + Details of people invited by other group members are not shown. If invitees choose to join, their information will be shared with the group at that time. They will not see any messages in the group until they join. + + + %1$s invited 1 person + %1$s invited %2$d people + + Group avatar Avatar @@ -1674,6 +1688,7 @@ All media Conversation settings Add to home screen + Pending members Expand popup diff --git a/app/src/main/res/values/text_styles.xml b/app/src/main/res/values/text_styles.xml index ab7719cd24..2fb5f8ce2f 100644 --- a/app/src/main/res/values/text_styles.xml +++ b/app/src/main/res/values/text_styles.xml @@ -97,6 +97,15 @@ bold + + + + + +