mirror of
https://github.com/oxen-io/session-android.git
synced 2025-06-07 23:08:33 +00:00
Join group via invite link.
This commit is contained in:
parent
b58376920f
commit
860f06ec9e
@ -227,6 +227,7 @@ import org.thoughtcrime.securesms.stickers.StickerLocator;
|
||||
import org.thoughtcrime.securesms.stickers.StickerManagementActivity;
|
||||
import org.thoughtcrime.securesms.stickers.StickerPackInstallEvent;
|
||||
import org.thoughtcrime.securesms.stickers.StickerSearchRepository;
|
||||
import org.thoughtcrime.securesms.util.AsynchronousCallback;
|
||||
import org.thoughtcrime.securesms.util.Base64;
|
||||
import org.thoughtcrime.securesms.util.BitmapUtil;
|
||||
import org.thoughtcrime.securesms.util.CharacterCalculator.CharacterState;
|
||||
@ -356,6 +357,8 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
private InputPanel inputPanel;
|
||||
private View panelParent;
|
||||
private View noLongerMemberBanner;
|
||||
private View requestingMemberBanner;
|
||||
private View cancelJoinRequest;
|
||||
private Stub<View> mentionsSuggestions;
|
||||
|
||||
private LinkPreviewViewModel linkPreviewViewModel;
|
||||
@ -383,12 +386,21 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
long threadId,
|
||||
int distributionType,
|
||||
int startingPosition)
|
||||
{
|
||||
Intent intent = buildIntent(context, recipientId, threadId);
|
||||
intent.putExtra(ConversationActivity.DISTRIBUTION_TYPE_EXTRA, distributionType);
|
||||
intent.putExtra(ConversationActivity.STARTING_POSITION_EXTRA, startingPosition);
|
||||
|
||||
return intent;
|
||||
}
|
||||
|
||||
public static @NonNull Intent buildIntent(@NonNull Context context,
|
||||
@NonNull RecipientId recipientId,
|
||||
long threadId)
|
||||
{
|
||||
Intent intent = new Intent(context, ConversationActivity.class);
|
||||
intent.putExtra(ConversationActivity.RECIPIENT_EXTRA, recipientId);
|
||||
intent.putExtra(ConversationActivity.THREAD_ID_EXTRA, threadId);
|
||||
intent.putExtra(ConversationActivity.DISTRIBUTION_TYPE_EXTRA, distributionType);
|
||||
intent.putExtra(ConversationActivity.STARTING_POSITION_EXTRA, startingPosition);
|
||||
|
||||
return intent;
|
||||
}
|
||||
@ -1452,14 +1464,64 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
}
|
||||
|
||||
private void initializeEnabledCheck() {
|
||||
groupViewModel.getGroupActiveState().observe(this, state -> {
|
||||
boolean inactivePushGroup = state != null && isPushGroupConversation() && !state.isActiveGroup();
|
||||
boolean enabled = !inactivePushGroup;
|
||||
noLongerMemberBanner.setVisibility(enabled ? View.GONE : View.VISIBLE);
|
||||
inputPanel.setVisibility(enabled ? View.VISIBLE : View.GONE);
|
||||
inputPanel.setEnabled(enabled);
|
||||
sendButton.setEnabled(enabled);
|
||||
attachButton.setEnabled(enabled);
|
||||
groupViewModel.getSelfMemberLevel().observe(this, selfMemberShip -> {
|
||||
boolean canSendMessages;
|
||||
boolean leftGroup;
|
||||
boolean canCancelRequest;
|
||||
|
||||
if (selfMemberShip == null) {
|
||||
leftGroup = false;
|
||||
canSendMessages = true;
|
||||
canCancelRequest = false;
|
||||
} else {
|
||||
switch (selfMemberShip) {
|
||||
case NOT_A_MEMBER:
|
||||
leftGroup = true;
|
||||
canSendMessages = false;
|
||||
canCancelRequest = false;
|
||||
break;
|
||||
case PENDING_MEMBER:
|
||||
leftGroup = false;
|
||||
canSendMessages = false;
|
||||
canCancelRequest = false;
|
||||
break;
|
||||
case REQUESTING_MEMBER:
|
||||
leftGroup = false;
|
||||
canSendMessages = false;
|
||||
canCancelRequest = true;
|
||||
break;
|
||||
case FULL_MEMBER:
|
||||
case ADMINISTRATOR:
|
||||
leftGroup = false;
|
||||
canSendMessages = true;
|
||||
canCancelRequest = false;
|
||||
break;
|
||||
default:
|
||||
throw new AssertionError();
|
||||
}
|
||||
}
|
||||
|
||||
noLongerMemberBanner.setVisibility(leftGroup ? View.VISIBLE : View.GONE);
|
||||
requestingMemberBanner.setVisibility(canCancelRequest ? View.VISIBLE : View.GONE);
|
||||
if (canCancelRequest) {
|
||||
cancelJoinRequest.setOnClickListener(v -> ConversationGroupViewModel.onCancelJoinRequest(getRecipient(), new AsynchronousCallback.MainThread<Void, GroupChangeFailureReason>() {
|
||||
@Override
|
||||
public void onComplete(@Nullable Void result) {
|
||||
Log.d(TAG, "Cancel request complete");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(@Nullable GroupChangeFailureReason error) {
|
||||
Log.d(TAG, "Cancel join request failed " + error);
|
||||
Toast.makeText(ConversationActivity.this, GroupErrors.getUserDisplayMessage(error), Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}.toWorkerCallback()));
|
||||
}
|
||||
|
||||
inputPanel.setVisibility(canSendMessages ? View.VISIBLE : View.GONE);
|
||||
inputPanel.setEnabled(canSendMessages);
|
||||
sendButton.setEnabled(canSendMessages);
|
||||
attachButton.setEnabled(canSendMessages);
|
||||
});
|
||||
}
|
||||
|
||||
@ -1755,6 +1817,8 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
ImageButton inlineAttachmentButton = ViewUtil.findById(this, R.id.inline_attachment_button);
|
||||
|
||||
noLongerMemberBanner = findViewById(R.id.conversation_no_longer_member_banner);
|
||||
requestingMemberBanner = findViewById(R.id.conversation_requesting_banner);
|
||||
cancelJoinRequest = findViewById(R.id.conversation_cancel_request);
|
||||
|
||||
container.addOnKeyboardShownListener(this);
|
||||
inputPanel.setListener(this);
|
||||
|
@ -1,6 +1,7 @@
|
||||
package org.thoughtcrime.securesms.conversation;
|
||||
|
||||
import android.content.Context;
|
||||
import android.text.TextUtils;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
import android.widget.TextView;
|
||||
@ -54,6 +55,7 @@ public class ConversationBannerView extends ConstraintLayout {
|
||||
|
||||
public void setSubtitle(@Nullable CharSequence subtitle) {
|
||||
contactSubtitle.setText(subtitle);
|
||||
contactSubtitle.setVisibility(TextUtils.isEmpty(subtitle) ? GONE : VISIBLE);
|
||||
}
|
||||
|
||||
public void setDescription(@Nullable CharSequence description) {
|
||||
|
@ -77,8 +77,8 @@ import org.thoughtcrime.securesms.conversation.ConversationAdapter.ItemClickList
|
||||
import org.thoughtcrime.securesms.conversation.ConversationAdapter.StickyHeaderViewHolder;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.MmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.MessageDatabase;
|
||||
import org.thoughtcrime.securesms.database.MmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
||||
import org.thoughtcrime.securesms.database.SmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
|
||||
@ -402,9 +402,11 @@ public class ConversationFragment extends LoggingFragment {
|
||||
conversationBanner.setSubtitle(context.getResources()
|
||||
.getQuantityString(R.plurals.MessageRequestProfileView_members_and_invited, memberCount,
|
||||
memberCount, pendingMemberCount));
|
||||
} else {
|
||||
} else if (memberCount > 0) {
|
||||
conversationBanner.setSubtitle(context.getResources().getQuantityString(R.plurals.MessageRequestProfileView_members, memberCount,
|
||||
memberCount));
|
||||
} else {
|
||||
conversationBanner.setSubtitle(null);
|
||||
}
|
||||
} else if (isSelf) {
|
||||
conversationBanner.setSubtitle(context.getString(R.string.ConversationFragment__you_can_add_notes_for_yourself_in_this_conversation));
|
||||
|
@ -14,18 +14,30 @@ import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase;
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase.GroupRecord;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.groups.GroupChangeBusyException;
|
||||
import org.thoughtcrime.securesms.groups.GroupChangeFailedException;
|
||||
import org.thoughtcrime.securesms.groups.GroupManager;
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.AsynchronousCallback;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
|
||||
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
|
||||
|
||||
class ConversationGroupViewModel extends ViewModel {
|
||||
import java.io.IOException;
|
||||
|
||||
final class ConversationGroupViewModel extends ViewModel {
|
||||
|
||||
private final MutableLiveData<Recipient> liveRecipient;
|
||||
private final LiveData<GroupActiveState> groupActiveState;
|
||||
private final LiveData<GroupDatabase.MemberLevel> selfMembershipLevel;
|
||||
|
||||
private ConversationGroupViewModel() {
|
||||
liveRecipient = new MutableLiveData<>();
|
||||
LiveData<GroupRecord> groupRecord = LiveDataUtil.mapAsync(liveRecipient, this::getGroupRecordForRecipient);
|
||||
groupActiveState = Transformations.distinctUntilChanged(Transformations.map(groupRecord, this::mapToGroupActiveState));
|
||||
this.liveRecipient = new MutableLiveData<>();
|
||||
|
||||
LiveData<GroupRecord> groupRecord = LiveDataUtil.mapAsync(liveRecipient, ConversationGroupViewModel::getGroupRecordForRecipient);
|
||||
|
||||
this.groupActiveState = Transformations.distinctUntilChanged(Transformations.map(groupRecord, ConversationGroupViewModel::mapToGroupActiveState));
|
||||
this.selfMembershipLevel = Transformations.distinctUntilChanged(Transformations.map(groupRecord, ConversationGroupViewModel::mapToSelfMembershipLevel));
|
||||
}
|
||||
|
||||
void onRecipientChange(Recipient recipient) {
|
||||
@ -36,7 +48,11 @@ class ConversationGroupViewModel extends ViewModel {
|
||||
return groupActiveState;
|
||||
}
|
||||
|
||||
private GroupRecord getGroupRecordForRecipient(Recipient recipient) {
|
||||
LiveData<GroupDatabase.MemberLevel> getSelfMemberLevel() {
|
||||
return selfMembershipLevel;
|
||||
}
|
||||
|
||||
private static @Nullable GroupRecord getGroupRecordForRecipient(@Nullable Recipient recipient) {
|
||||
if (recipient != null && recipient.isGroup()) {
|
||||
Application context = ApplicationDependencies.getApplication();
|
||||
GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context);
|
||||
@ -46,13 +62,37 @@ class ConversationGroupViewModel extends ViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
private GroupActiveState mapToGroupActiveState(@Nullable GroupRecord record) {
|
||||
private static GroupActiveState mapToGroupActiveState(@Nullable GroupRecord record) {
|
||||
if (record == null) {
|
||||
return null;
|
||||
}
|
||||
return new GroupActiveState(record.isActive(), record.isV2Group());
|
||||
}
|
||||
|
||||
private static GroupDatabase.MemberLevel mapToSelfMembershipLevel(@Nullable GroupRecord record) {
|
||||
if (record == null) {
|
||||
return null;
|
||||
}
|
||||
return record.memberLevel(Recipient.self());
|
||||
}
|
||||
|
||||
public static void onCancelJoinRequest(@NonNull Recipient recipient,
|
||||
@NonNull AsynchronousCallback.WorkerThread<Void, GroupChangeFailureReason> callback)
|
||||
{
|
||||
SignalExecutors.UNBOUNDED.execute(() -> {
|
||||
if (!recipient.isPushV2Group()) {
|
||||
throw new AssertionError();
|
||||
}
|
||||
|
||||
try {
|
||||
GroupManager.cancelJoinRequest(ApplicationDependencies.getApplication(), recipient.getGroupId().get().requireV2());
|
||||
callback.onComplete(null);
|
||||
} catch (GroupChangeFailedException | GroupChangeBusyException | IOException e) {
|
||||
callback.onError(GroupChangeFailureReason.fromException(e));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static final class GroupActiveState {
|
||||
private final boolean isActive;
|
||||
private final boolean isActiveV2;
|
||||
|
@ -129,6 +129,15 @@ public final class GroupDatabase extends Database {
|
||||
}
|
||||
}
|
||||
|
||||
public boolean findGroup(@NonNull GroupId groupId) {
|
||||
try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, GROUP_ID + " = ?",
|
||||
new String[] {groupId.toString()},
|
||||
null, null, null))
|
||||
{
|
||||
return cursor.moveToNext();
|
||||
}
|
||||
}
|
||||
|
||||
Optional<GroupRecord> getGroup(Cursor cursor) {
|
||||
Reader reader = new Reader(cursor);
|
||||
return Optional.fromNullable(reader.getCurrent());
|
||||
@ -364,7 +373,13 @@ public final class GroupDatabase extends Database {
|
||||
|
||||
contentValues.put(AVATAR_RELAY, relay);
|
||||
contentValues.put(TIMESTAMP, System.currentTimeMillis());
|
||||
|
||||
if (groupId.isV2()) {
|
||||
contentValues.put(ACTIVE, groupState != null && gv2GroupActive(groupState) ? 1 : 0);
|
||||
} else {
|
||||
contentValues.put(ACTIVE, 1);
|
||||
}
|
||||
|
||||
contentValues.put(MMS, groupId.isMms());
|
||||
|
||||
if (groupMasterKey != null) {
|
||||
@ -428,14 +443,12 @@ public final class GroupDatabase extends Database {
|
||||
RecipientId groupRecipientId = recipientDatabase.getOrInsertFromGroupId(groupId);
|
||||
String title = decryptedGroup.getTitle();
|
||||
ContentValues contentValues = new ContentValues();
|
||||
UUID uuid = Recipient.self().getUuid().get();
|
||||
|
||||
contentValues.put(TITLE, title);
|
||||
contentValues.put(V2_REVISION, decryptedGroup.getRevision());
|
||||
contentValues.put(V2_DECRYPTED_GROUP, decryptedGroup.toByteArray());
|
||||
contentValues.put(MEMBERS, serializeV2GroupMembers(decryptedGroup));
|
||||
contentValues.put(ACTIVE, DecryptedGroupUtil.findMemberByUuid(decryptedGroup.getMembersList(), uuid).isPresent() ||
|
||||
DecryptedGroupUtil.findPendingByUuid(decryptedGroup.getPendingMembersList(), uuid).isPresent() ? 1 : 0);
|
||||
contentValues.put(ACTIVE, gv2GroupActive(decryptedGroup) ? 1 : 0);
|
||||
|
||||
databaseHelper.getWritableDatabase().update(TABLE_NAME, contentValues,
|
||||
GROUP_ID + " = ?",
|
||||
@ -502,6 +515,13 @@ public final class GroupDatabase extends Database {
|
||||
Recipient.live(groupRecipient).refresh();
|
||||
}
|
||||
|
||||
private static boolean gv2GroupActive(@NonNull DecryptedGroup decryptedGroup) {
|
||||
UUID uuid = Recipient.self().getUuid().get();
|
||||
|
||||
return DecryptedGroupUtil.findMemberByUuid(decryptedGroup.getMembersList(), uuid).isPresent() ||
|
||||
DecryptedGroupUtil.findPendingByUuid(decryptedGroup.getPendingMembersList(), uuid).isPresent();
|
||||
}
|
||||
|
||||
private List<RecipientId> getCurrentMembers(@NonNull GroupId groupId) {
|
||||
Cursor cursor = null;
|
||||
|
||||
@ -843,8 +863,10 @@ public final class GroupDatabase extends Database {
|
||||
? MemberLevel.ADMINISTRATOR
|
||||
: MemberLevel.FULL_MEMBER)
|
||||
.or(() -> DecryptedGroupUtil.findPendingByUuid(decryptedGroup.getPendingMembersList(), recipient.getUuid().get())
|
||||
.isPresent() ? MemberLevel.PENDING_MEMBER
|
||||
: MemberLevel.NOT_A_MEMBER);
|
||||
.transform(m -> MemberLevel.PENDING_MEMBER)
|
||||
.or(() -> DecryptedGroupUtil.findRequestingByUuid(decryptedGroup.getRequestingMembersList(), recipient.getUuid().get())
|
||||
.transform(m -> MemberLevel.REQUESTING_MEMBER)
|
||||
.or(MemberLevel.NOT_A_MEMBER)));
|
||||
}
|
||||
|
||||
public List<Recipient> getMemberRecipients(@NonNull MemberSet memberSet) {
|
||||
@ -902,6 +924,7 @@ public final class GroupDatabase extends Database {
|
||||
public enum MemberLevel {
|
||||
NOT_A_MEMBER(false),
|
||||
PENDING_MEMBER(false),
|
||||
REQUESTING_MEMBER(false),
|
||||
FULL_MEMBER(true),
|
||||
ADMINISTRATOR(true);
|
||||
|
||||
|
@ -583,11 +583,17 @@ final class GroupsV2UpdateMessageProducer {
|
||||
|
||||
if (requestingMemberIsYou) {
|
||||
updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_approved_your_request_to_join_the_group, editor)));
|
||||
} else {
|
||||
boolean editorIsYou = change.getEditor().equals(selfUuidBytes);
|
||||
|
||||
if (editorIsYou) {
|
||||
updates.add(updateDescription(requestingMember.getUuid(), requesting -> context.getString(R.string.MessageRecord_you_approved_a_request_to_join_the_group_from_s, requesting)));
|
||||
} else {
|
||||
updates.add(updateDescription(change.getEditor(), requestingMember.getUuid(), (editor, requesting) -> context.getString(R.string.MessageRecord_s_approved_a_request_to_join_the_group_from_s, editor, requesting)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void describeUnknownEditorRequestingMembersApprovals(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
|
||||
for (DecryptedApproveMember requestingMember : change.getPromoteRequestingMembersList()) {
|
||||
@ -602,16 +608,28 @@ final class GroupsV2UpdateMessageProducer {
|
||||
}
|
||||
|
||||
private void describeRequestingMembersDeletes(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
|
||||
boolean editorIsYou = change.getEditor().equals(selfUuidBytes);
|
||||
|
||||
for (ByteString requestingMember : change.getDeleteRequestingMembersList()) {
|
||||
boolean requestingMemberIsYou = requestingMember.equals(selfUuidBytes);
|
||||
|
||||
if (requestingMemberIsYou) {
|
||||
if (editorIsYou) {
|
||||
updates.add(updateDescription(context.getString(R.string.MessageRecord_you_canceled_your_request_to_join_the_group)));
|
||||
} else {
|
||||
updates.add(updateDescription(context.getString(R.string.MessageRecord_your_request_to_join_the_group_has_been_denied_by_an_admin)));
|
||||
}
|
||||
} else {
|
||||
boolean editorIsCanceledMember = change.getEditor().equals(requestingMember);
|
||||
|
||||
if (editorIsCanceledMember) {
|
||||
updates.add(updateDescription(requestingMember, editorRequesting -> context.getString(R.string.MessageRecord_s_canceled_their_request_to_join_the_group, editorRequesting)));
|
||||
} else {
|
||||
updates.add(updateDescription(change.getEditor(), requestingMember, (editor, requesting) -> context.getString(R.string.MessageRecord_s_denied_a_request_to_join_the_group_from_s, editor, requesting)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void describeUnknownEditorRequestingMembersDeletes(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
|
||||
for (ByteString requestingMember : change.getDeleteRequestingMembersList()) {
|
||||
|
@ -174,7 +174,7 @@ public abstract class MessageRecord extends DisplayRecord {
|
||||
DecryptedGroupV2Context decryptedGroupV2Context = DecryptedGroupV2Context.parseFrom(decoded);
|
||||
GroupsV2UpdateMessageProducer updateMessageProducer = new GroupsV2UpdateMessageProducer(context, descriptionStrategy, Recipient.self().getUuid().get());
|
||||
|
||||
if (decryptedGroupV2Context.hasChange() && decryptedGroupV2Context.getGroupState().getRevision() > 0) {
|
||||
if (decryptedGroupV2Context.hasChange() && decryptedGroupV2Context.getGroupState().getRevision() != 0) {
|
||||
return UpdateDescription.concatWithNewLines(updateMessageProducer.describeChanges(decryptedGroupV2Context.getChange()));
|
||||
} else {
|
||||
return updateMessageProducer.describeNewGroup(decryptedGroupV2Context.getGroupState());
|
||||
|
@ -0,0 +1,10 @@
|
||||
package org.thoughtcrime.securesms.groups;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
public final class GroupJoinAlreadyAMemberException extends GroupChangeException {
|
||||
|
||||
GroupJoinAlreadyAMemberException(@NonNull Throwable throwable) {
|
||||
super(throwable);
|
||||
}
|
||||
}
|
@ -145,6 +145,12 @@ public final class GroupManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws GroupNotAMemberException When Self is not a member of the group.
|
||||
* The exception to this is when Self is a requesting member and
|
||||
* there is a supplied signedGroupChange. This allows for
|
||||
* processing deny messages.
|
||||
*/
|
||||
@WorkerThread
|
||||
public static void updateGroupFromServer(@NonNull Context context,
|
||||
@NonNull GroupMasterKey groupMasterKey,
|
||||
@ -174,6 +180,11 @@ public final class GroupManager {
|
||||
public static void updateSelfProfileKeyInGroup(@NonNull Context context, @NonNull GroupId.V2 groupId)
|
||||
throws IOException, GroupChangeBusyException, GroupInsufficientRightsException, GroupNotAMemberException, GroupChangeFailedException
|
||||
{
|
||||
if (!DatabaseFactory.getGroupDatabase(context).findGroup(groupId)) {
|
||||
Log.i(TAG, "Group is not available locally " + groupId);
|
||||
return;
|
||||
}
|
||||
|
||||
try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) {
|
||||
editor.updateSelfProfileKeyInGroup();
|
||||
}
|
||||
@ -266,12 +277,35 @@ public final class GroupManager {
|
||||
@WorkerThread
|
||||
public static @NonNull DecryptedGroupJoinInfo getGroupJoinInfoFromServer(@NonNull Context context,
|
||||
@NonNull GroupMasterKey groupMasterKey,
|
||||
@NonNull GroupLinkPassword groupLinkPassword)
|
||||
@Nullable GroupLinkPassword groupLinkPassword)
|
||||
throws IOException, VerificationFailedException, GroupLinkNotActiveException
|
||||
{
|
||||
return new GroupManagerV2(context).getGroupJoinInfoFromServer(groupMasterKey, groupLinkPassword);
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public static GroupActionResult joinGroup(@NonNull Context context,
|
||||
@NonNull GroupMasterKey groupMasterKey,
|
||||
@NonNull GroupLinkPassword groupLinkPassword,
|
||||
@NonNull DecryptedGroupJoinInfo decryptedGroupJoinInfo,
|
||||
@Nullable byte[] avatar)
|
||||
throws IOException, GroupChangeBusyException, GroupChangeFailedException, MembershipNotSuitableForV2Exception, GroupLinkNotActiveException
|
||||
{
|
||||
try (GroupManagerV2.GroupJoiner join = new GroupManagerV2(context).join(groupMasterKey, groupLinkPassword)) {
|
||||
return join.joinGroup(decryptedGroupJoinInfo, avatar);
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public static void cancelJoinRequest(@NonNull Context context,
|
||||
@NonNull GroupId.V2 groupId)
|
||||
throws GroupChangeFailedException, IOException, GroupChangeBusyException
|
||||
{
|
||||
try (GroupManagerV2.GroupJoiner editor = new GroupManagerV2(context).cancelRequest(groupId.requireV2())) {
|
||||
editor.cancelJoinRequest();
|
||||
}
|
||||
}
|
||||
|
||||
public static class GroupActionResult {
|
||||
private final Recipient groupRecipient;
|
||||
private final long threadId;
|
||||
|
@ -6,7 +6,9 @@ import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import com.annimon.stream.Collectors;
|
||||
import com.annimon.stream.Stream;
|
||||
import com.google.protobuf.ByteString;
|
||||
import com.google.protobuf.InvalidProtocolBufferException;
|
||||
|
||||
import org.signal.storageservice.protos.groups.AccessControl;
|
||||
@ -17,21 +19,25 @@ import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroupJoinInfo;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedMember;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedPendingMember;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedRequestingMember;
|
||||
import org.signal.zkgroup.InvalidInputException;
|
||||
import org.signal.zkgroup.VerificationFailedException;
|
||||
import org.signal.zkgroup.groups.GroupMasterKey;
|
||||
import org.signal.zkgroup.groups.GroupSecretParams;
|
||||
import org.signal.zkgroup.groups.UuidCiphertext;
|
||||
import org.signal.zkgroup.profiles.ProfileKey;
|
||||
import org.signal.zkgroup.profiles.ProfileKeyCredential;
|
||||
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase;
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.groups.v2.GroupCandidateHelper;
|
||||
import org.thoughtcrime.securesms.groups.v2.GroupLinkPassword;
|
||||
import org.thoughtcrime.securesms.groups.v2.processing.GroupsV2StateProcessor;
|
||||
import org.thoughtcrime.securesms.jobs.PushGroupSilentUpdateSendJob;
|
||||
import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.mms.OutgoingGroupUpdateMessage;
|
||||
@ -62,6 +68,7 @@ import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
@ -89,12 +96,14 @@ final class GroupManagerV2 {
|
||||
this.groupCandidateHelper = new GroupCandidateHelper(context);
|
||||
}
|
||||
|
||||
@NonNull DecryptedGroupJoinInfo getGroupJoinInfoFromServer(@NonNull GroupMasterKey groupMasterKey, @NonNull GroupLinkPassword password)
|
||||
@NonNull DecryptedGroupJoinInfo getGroupJoinInfoFromServer(@NonNull GroupMasterKey groupMasterKey, @Nullable GroupLinkPassword password)
|
||||
throws IOException, VerificationFailedException, GroupLinkNotActiveException
|
||||
{
|
||||
GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
|
||||
|
||||
return groupsV2Api.getGroupJoinInfo(groupSecretParams, password.serialize(), authorization.getAuthorizationForToday(Recipient.self().requireUuid(), groupSecretParams));
|
||||
return groupsV2Api.getGroupJoinInfo(groupSecretParams,
|
||||
Optional.fromNullable(password).transform(GroupLinkPassword::serialize),
|
||||
authorization.getAuthorizationForToday(Recipient.self().requireUuid(), groupSecretParams));
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
@ -107,17 +116,30 @@ final class GroupManagerV2 {
|
||||
return new GroupEditor(groupId, GroupsV2ProcessingLock.acquireGroupProcessingLock());
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
GroupJoiner join(@NonNull GroupMasterKey groupMasterKey, @NonNull GroupLinkPassword password) throws GroupChangeBusyException {
|
||||
return new GroupJoiner(groupMasterKey, password, GroupsV2ProcessingLock.acquireGroupProcessingLock());
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
GroupJoiner cancelRequest(@NonNull GroupId.V2 groupId) throws GroupChangeBusyException {
|
||||
GroupMasterKey groupMasterKey = DatabaseFactory.getGroupDatabase(context)
|
||||
.requireGroup(groupId)
|
||||
.requireV2GroupProperties()
|
||||
.getGroupMasterKey();
|
||||
|
||||
return new GroupJoiner(groupMasterKey, null, GroupsV2ProcessingLock.acquireGroupProcessingLock());
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
GroupUpdater updater(@NonNull GroupMasterKey groupId) throws GroupChangeBusyException {
|
||||
return new GroupUpdater(groupId, GroupsV2ProcessingLock.acquireGroupProcessingLock());
|
||||
}
|
||||
|
||||
class GroupCreator implements Closeable {
|
||||
|
||||
private final Closeable lock;
|
||||
final class GroupCreator extends LockOwner {
|
||||
|
||||
GroupCreator(@NonNull Closeable lock) {
|
||||
this.lock = lock;
|
||||
super(lock);
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
@ -176,26 +198,21 @@ final class GroupManagerV2 {
|
||||
throw new GroupChangeFailedException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
lock.close();
|
||||
}
|
||||
}
|
||||
|
||||
class GroupEditor implements Closeable {
|
||||
final class GroupEditor extends LockOwner {
|
||||
|
||||
private final Closeable lock;
|
||||
private final GroupId.V2 groupId;
|
||||
private final GroupMasterKey groupMasterKey;
|
||||
private final GroupSecretParams groupSecretParams;
|
||||
private final GroupsV2Operations.GroupOperations groupOperations;
|
||||
|
||||
GroupEditor(@NonNull GroupId.V2 groupId, @NonNull Closeable lock) {
|
||||
super(lock);
|
||||
|
||||
GroupDatabase.GroupRecord groupRecord = groupDatabase.requireGroup(groupId);
|
||||
GroupDatabase.V2GroupProperties v2GroupProperties = groupRecord.requireV2GroupProperties();
|
||||
|
||||
this.lock = lock;
|
||||
this.groupId = groupId;
|
||||
this.groupMasterKey = v2GroupProperties.getGroupMasterKey();
|
||||
this.groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
|
||||
@ -275,6 +292,17 @@ final class GroupManagerV2 {
|
||||
return commitChangeWithConflictResolution(groupOperations.createRemoveInvitationChange(new HashSet<>(uuidCipherTexts)));
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
@NonNull GroupManager.GroupActionResult denyRequests(@NonNull Collection<RecipientId> recipientIds)
|
||||
throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException
|
||||
{
|
||||
Set<UUID> uuids = Stream.of(recipientIds)
|
||||
.map(r -> Recipient.resolved(r).getUuid().get())
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
return commitChangeWithConflictResolution(groupOperations.createRefuseGroupJoinRequest(uuids));
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
@NonNull GroupManager.GroupActionResult setMemberAdmin(@NonNull RecipientId recipientId,
|
||||
boolean admin)
|
||||
@ -333,7 +361,7 @@ final class GroupManagerV2 {
|
||||
Optional<DecryptedMember> selfInGroup = DecryptedGroupUtil.findMemberByUuid(group.getMembersList(), selfUuid);
|
||||
|
||||
if (!selfInGroup.isPresent()) {
|
||||
Log.w(TAG, "Self not in group");
|
||||
Log.w(TAG, "Self not in group " + groupId);
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -456,7 +484,7 @@ final class GroupManagerV2 {
|
||||
throws GroupNotAMemberException, GroupChangeFailedException, IOException, GroupInsufficientRightsException
|
||||
{
|
||||
try {
|
||||
return groupsV2Api.patchGroup(change, authorization.getAuthorizationForToday(selfUuid, groupSecretParams));
|
||||
return groupsV2Api.patchGroup(change, authorization.getAuthorizationForToday(selfUuid, groupSecretParams), Optional.absent());
|
||||
} catch (NotInGroupException e) {
|
||||
Log.w(TAG, e);
|
||||
throw new GroupNotAMemberException(e);
|
||||
@ -468,20 +496,15 @@ final class GroupManagerV2 {
|
||||
throw new GroupChangeFailedException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
lock.close();
|
||||
}
|
||||
}
|
||||
|
||||
class GroupUpdater implements Closeable {
|
||||
final class GroupUpdater extends LockOwner {
|
||||
|
||||
private final Closeable lock;
|
||||
private final GroupMasterKey groupMasterKey;
|
||||
|
||||
GroupUpdater(@NonNull GroupMasterKey groupMasterKey, @NonNull Closeable lock) {
|
||||
this.lock = lock;
|
||||
super(lock);
|
||||
|
||||
this.groupMasterKey = groupMasterKey;
|
||||
}
|
||||
|
||||
@ -507,6 +530,377 @@ final class GroupManagerV2 {
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
final class GroupJoiner extends LockOwner {
|
||||
private final GroupId.V2 groupId;
|
||||
private final GroupLinkPassword password;
|
||||
private final GroupSecretParams groupSecretParams;
|
||||
private final GroupsV2Operations.GroupOperations groupOperations;
|
||||
private final GroupMasterKey groupMasterKey;
|
||||
|
||||
public GroupJoiner(@NonNull GroupMasterKey groupMasterKey,
|
||||
@Nullable GroupLinkPassword password,
|
||||
@NonNull Closeable lock)
|
||||
{
|
||||
super(lock);
|
||||
|
||||
this.groupId = GroupId.v2(groupMasterKey);
|
||||
this.password = password;
|
||||
this.groupMasterKey = groupMasterKey;
|
||||
this.groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
|
||||
this.groupOperations = groupsV2Operations.forGroup(groupSecretParams);
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public GroupManager.GroupActionResult joinGroup(@NonNull DecryptedGroupJoinInfo joinInfo,
|
||||
@Nullable byte[] avatar)
|
||||
throws GroupChangeFailedException, IOException, MembershipNotSuitableForV2Exception, GroupLinkNotActiveException
|
||||
{
|
||||
boolean requestToJoin = joinInfo.getAddFromInviteLink() == AccessControl.AccessRequired.ADMINISTRATOR;
|
||||
boolean alreadyAMember = false;
|
||||
|
||||
if (requestToJoin) {
|
||||
Log.i(TAG, "Requesting to join " + groupId);
|
||||
} else {
|
||||
Log.i(TAG, "Joining " + groupId);
|
||||
}
|
||||
|
||||
GroupChange signedGroupChange = null;
|
||||
DecryptedGroupChange decryptedChange = null;
|
||||
try {
|
||||
signedGroupChange = joinGroupOnServer(requestToJoin, joinInfo.getRevision());
|
||||
|
||||
if (requestToJoin) {
|
||||
Log.i(TAG, String.format("Successfully requested to join %s on server", groupId));
|
||||
} else {
|
||||
Log.i(TAG, String.format("Successfully added self to %s on server", groupId));
|
||||
}
|
||||
|
||||
decryptedChange = decryptChange(signedGroupChange);
|
||||
} catch (GroupJoinAlreadyAMemberException e) {
|
||||
Log.i(TAG, "Server reports that we are already a member of " + groupId);
|
||||
alreadyAMember = true;
|
||||
}
|
||||
|
||||
DecryptedGroup decryptedGroup = createPlaceholderGroup(joinInfo, requestToJoin);
|
||||
|
||||
Optional<GroupDatabase.GroupRecord> group = groupDatabase.getGroup(groupId);
|
||||
if (group.isPresent()) {
|
||||
Log.i(TAG, "Group already present locally");
|
||||
|
||||
DecryptedGroup currentGroupState = group.get()
|
||||
.requireV2GroupProperties()
|
||||
.getDecryptedGroup();
|
||||
|
||||
DecryptedGroup updatedGroup = currentGroupState;
|
||||
|
||||
try {
|
||||
if (decryptedChange != null) {
|
||||
updatedGroup = DecryptedGroupUtil.applyWithoutRevisionCheck(updatedGroup, decryptedChange);
|
||||
}
|
||||
updatedGroup = resetRevision(updatedGroup, currentGroupState.getRevision());
|
||||
} catch (NotAbleToApplyGroupV2ChangeException e) {
|
||||
Log.w(TAG, e);
|
||||
updatedGroup = decryptedGroup;
|
||||
}
|
||||
|
||||
groupDatabase.update(groupId, updatedGroup);
|
||||
} else {
|
||||
groupDatabase.create(groupMasterKey, decryptedGroup);
|
||||
Log.i(TAG, "Created local group with placeholder");
|
||||
}
|
||||
|
||||
RecipientId groupRecipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId);
|
||||
Recipient groupRecipient = Recipient.resolved(groupRecipientId);
|
||||
|
||||
AvatarHelper.setAvatar(context, groupRecipientId, avatar != null ? new ByteArrayInputStream(avatar) : null);
|
||||
groupDatabase.onAvatarUpdated(groupId, avatar != null);
|
||||
DatabaseFactory.getRecipientDatabase(context).setProfileSharing(groupRecipientId, true);
|
||||
|
||||
if (alreadyAMember) {
|
||||
Log.i(TAG, "Already a member of the group");
|
||||
|
||||
ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context);
|
||||
long threadId = threadDatabase.getOrCreateValidThreadId(groupRecipient, -1);
|
||||
|
||||
return new GroupManager.GroupActionResult(groupRecipient,
|
||||
threadId,
|
||||
0,
|
||||
Collections.emptyList());
|
||||
} else if (requestToJoin) {
|
||||
Log.i(TAG, "Requested to join, cannot send update");
|
||||
|
||||
RecipientAndThread recipientAndThread = sendGroupUpdate(groupMasterKey, decryptedGroup, decryptedChange, signedGroupChange);
|
||||
|
||||
return new GroupManager.GroupActionResult(groupRecipient,
|
||||
recipientAndThread.threadId,
|
||||
0,
|
||||
Collections.emptyList());
|
||||
} else {
|
||||
Log.i(TAG, "Joined group on server, fetching group state and sending update");
|
||||
|
||||
return fetchGroupStateAndSendUpdate(groupRecipient, decryptedGroup, decryptedChange, signedGroupChange);
|
||||
}
|
||||
}
|
||||
|
||||
private GroupManager.GroupActionResult fetchGroupStateAndSendUpdate(@NonNull Recipient groupRecipient,
|
||||
@NonNull DecryptedGroup decryptedGroup,
|
||||
@NonNull DecryptedGroupChange decryptedChange,
|
||||
@NonNull GroupChange signedGroupChange)
|
||||
throws GroupChangeFailedException, IOException
|
||||
{
|
||||
try {
|
||||
new GroupsV2StateProcessor(context).forGroup(groupMasterKey)
|
||||
.updateLocalGroupToRevision(decryptedChange.getRevision(),
|
||||
System.currentTimeMillis(),
|
||||
decryptedChange);
|
||||
|
||||
RecipientAndThread recipientAndThread = sendGroupUpdate(groupMasterKey, decryptedGroup, decryptedChange, signedGroupChange);
|
||||
|
||||
return new GroupManager.GroupActionResult(groupRecipient,
|
||||
recipientAndThread.threadId,
|
||||
1,
|
||||
Collections.emptyList());
|
||||
} catch (GroupNotAMemberException e) {
|
||||
Log.w(TAG, "Despite adding self to group, server says we are not a member, scheduling refresh of group info " + groupId, e);
|
||||
|
||||
ApplicationDependencies.getJobManager()
|
||||
.add(new RequestGroupV2InfoJob(groupId));
|
||||
|
||||
throw new GroupChangeFailedException(e);
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "Group data fetch failed, scheduling refresh of group info " + groupId, e);
|
||||
|
||||
ApplicationDependencies.getJobManager()
|
||||
.add(new RequestGroupV2InfoJob(groupId));
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private @NonNull DecryptedGroupChange decryptChange(@NonNull GroupChange signedGroupChange)
|
||||
throws GroupChangeFailedException
|
||||
{
|
||||
try {
|
||||
return groupOperations.decryptChange(signedGroupChange, false).get();
|
||||
} catch (VerificationFailedException | InvalidGroupStateException | InvalidProtocolBufferException e) {
|
||||
Log.w(TAG, e);
|
||||
throw new GroupChangeFailedException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a local group from what we know before joining.
|
||||
* <p>
|
||||
* Creates as a {@link GroupsV2StateProcessor#PLACEHOLDER_REVISION} so that we know not do do a
|
||||
* full diff against this group once we learn more about this group as that would create a large
|
||||
* update message.
|
||||
*/
|
||||
private DecryptedGroup createPlaceholderGroup(@NonNull DecryptedGroupJoinInfo joinInfo, boolean requestToJoin) {
|
||||
DecryptedGroup.Builder group = DecryptedGroup.newBuilder()
|
||||
.setTitle(joinInfo.getTitle())
|
||||
.setAvatar(joinInfo.getAvatar())
|
||||
.setRevision(GroupsV2StateProcessor.PLACEHOLDER_REVISION);
|
||||
|
||||
Recipient self = Recipient.self();
|
||||
ByteString selfUuid = UuidUtil.toByteString(self.requireUuid());
|
||||
ByteString profileKey = ByteString.copyFrom(Objects.requireNonNull(self.getProfileKey()));
|
||||
|
||||
if (requestToJoin) {
|
||||
group.addRequestingMembers(DecryptedRequestingMember.newBuilder()
|
||||
.setUuid(selfUuid)
|
||||
.setProfileKey(profileKey));
|
||||
} else {
|
||||
group.addMembers(DecryptedMember.newBuilder()
|
||||
.setUuid(selfUuid)
|
||||
.setProfileKey(profileKey));
|
||||
}
|
||||
|
||||
return group.build();
|
||||
}
|
||||
|
||||
private @NonNull GroupChange joinGroupOnServer(boolean requestToJoin, int currentRevision)
|
||||
throws GroupChangeFailedException, IOException, MembershipNotSuitableForV2Exception, GroupLinkNotActiveException, GroupJoinAlreadyAMemberException
|
||||
{
|
||||
if (!GroupsV2CapabilityChecker.allAndSelfSupportGroupsV2AndUuid(Collections.singleton(Recipient.self().getId()))) {
|
||||
throw new MembershipNotSuitableForV2Exception("Self does not support GV2 or UUID capabilities");
|
||||
}
|
||||
|
||||
GroupCandidate self = groupCandidateHelper.recipientIdToCandidate(Recipient.self().getId());
|
||||
|
||||
if (!self.hasProfileKeyCredential()) {
|
||||
throw new MembershipNotSuitableForV2Exception("No profile key credential for self");
|
||||
}
|
||||
|
||||
ProfileKeyCredential profileKeyCredential = self.getProfileKeyCredential().get();
|
||||
|
||||
GroupChange.Actions.Builder change = requestToJoin ? groupOperations.createGroupJoinRequest(profileKeyCredential)
|
||||
: groupOperations.createGroupJoinDirect(profileKeyCredential);
|
||||
|
||||
change.setSourceUuid(UuidUtil.toByteString(Recipient.self().getUuid().get()));
|
||||
|
||||
return commitJoinChangeWithConflictResolution(currentRevision, change);
|
||||
}
|
||||
|
||||
private @NonNull GroupChange commitJoinChangeWithConflictResolution(int currentRevision, @NonNull GroupChange.Actions.Builder change)
|
||||
throws GroupChangeFailedException, IOException, GroupLinkNotActiveException, GroupJoinAlreadyAMemberException
|
||||
{
|
||||
for (int attempt = 0; attempt < 5; attempt++) {
|
||||
try {
|
||||
GroupChange.Actions changeActions = change.setRevision(currentRevision + 1)
|
||||
.build();
|
||||
|
||||
Log.i(TAG, "Trying to join group at V" + changeActions.getRevision());
|
||||
GroupChange signedGroupChange = commitJoinToServer(changeActions);
|
||||
|
||||
Log.i(TAG, "Successfully joined group at V" + changeActions.getRevision());
|
||||
return signedGroupChange;
|
||||
} catch (GroupPatchNotAcceptedException e) {
|
||||
Log.w(TAG, "Patch not accepted", e);
|
||||
|
||||
try {
|
||||
if (alreadyPendingAdminApproval() || testGroupMembership()) {
|
||||
throw new GroupJoinAlreadyAMemberException(e);
|
||||
} else {
|
||||
throw new GroupChangeFailedException(e);
|
||||
}
|
||||
} catch (VerificationFailedException | InvalidGroupStateException ex) {
|
||||
throw new GroupChangeFailedException(ex);
|
||||
}
|
||||
} catch (ConflictException e) {
|
||||
Log.w(TAG, "Revision conflict", e);
|
||||
|
||||
currentRevision = getCurrentGroupRevisionFromServer();
|
||||
}
|
||||
}
|
||||
|
||||
throw new GroupChangeFailedException("Unable to join group after conflicts");
|
||||
}
|
||||
|
||||
private @NonNull GroupChange commitJoinToServer(@NonNull GroupChange.Actions change)
|
||||
throws GroupChangeFailedException, IOException, GroupLinkNotActiveException
|
||||
{
|
||||
try {
|
||||
return groupsV2Api.patchGroup(change, authorization.getAuthorizationForToday(selfUuid, groupSecretParams), Optional.fromNullable(password).transform(GroupLinkPassword::serialize));
|
||||
} catch (NotInGroupException | VerificationFailedException e) {
|
||||
Log.w(TAG, e);
|
||||
throw new GroupChangeFailedException(e);
|
||||
} catch (AuthorizationFailedException e) {
|
||||
Log.w(TAG, e);
|
||||
throw new GroupLinkNotActiveException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private int getCurrentGroupRevisionFromServer()
|
||||
throws IOException, GroupLinkNotActiveException, GroupChangeFailedException
|
||||
{
|
||||
try {
|
||||
int currentRevision = getGroupJoinInfoFromServer(groupMasterKey, password).getRevision();
|
||||
|
||||
Log.i(TAG, "Server now on V" + currentRevision);
|
||||
|
||||
return currentRevision;
|
||||
} catch (VerificationFailedException ex) {
|
||||
throw new GroupChangeFailedException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean alreadyPendingAdminApproval()
|
||||
throws IOException, GroupLinkNotActiveException, GroupChangeFailedException
|
||||
{
|
||||
try {
|
||||
boolean pendingAdminApproval = getGroupJoinInfoFromServer(groupMasterKey, password).getPendingAdminApproval();
|
||||
|
||||
if (pendingAdminApproval) {
|
||||
Log.i(TAG, "User is already pending admin approval");
|
||||
}
|
||||
|
||||
return pendingAdminApproval;
|
||||
} catch (VerificationFailedException ex) {
|
||||
throw new GroupChangeFailedException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean testGroupMembership()
|
||||
throws IOException, VerificationFailedException, InvalidGroupStateException
|
||||
{
|
||||
try {
|
||||
groupsV2Api.getGroup(groupSecretParams, authorization.getAuthorizationForToday(Recipient.self().requireUuid(), groupSecretParams));
|
||||
return true;
|
||||
} catch (NotInGroupException ex) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
void cancelJoinRequest()
|
||||
throws GroupChangeFailedException, IOException
|
||||
{
|
||||
Set<UUID> uuids = Collections.singleton(Recipient.self().getUuid().get());
|
||||
|
||||
GroupChange signedGroupChange;
|
||||
try {
|
||||
signedGroupChange = commitCancelChangeWithConflictResolution(groupOperations.createRefuseGroupJoinRequest(uuids));
|
||||
} catch (GroupLinkNotActiveException e) {
|
||||
Log.d(TAG, "Unexpected unable to leave group due to group link off");
|
||||
throw new GroupChangeFailedException(e);
|
||||
}
|
||||
|
||||
DecryptedGroup decryptedGroup = groupDatabase.requireGroup(groupId).requireV2GroupProperties().getDecryptedGroup();
|
||||
|
||||
try {
|
||||
DecryptedGroupChange decryptedChange = groupOperations.decryptChange(signedGroupChange, false).get();
|
||||
DecryptedGroup newGroup = DecryptedGroupUtil.applyWithoutRevisionCheck(decryptedGroup, decryptedChange);
|
||||
|
||||
groupDatabase.update(groupId, resetRevision(newGroup, decryptedGroup.getRevision()));
|
||||
|
||||
sendGroupUpdate(groupMasterKey, decryptedGroup, decryptedChange, signedGroupChange);
|
||||
} catch (VerificationFailedException | InvalidGroupStateException | NotAbleToApplyGroupV2ChangeException e) {
|
||||
throw new GroupChangeFailedException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private DecryptedGroup resetRevision(DecryptedGroup newGroup, int revision) {
|
||||
return DecryptedGroup.newBuilder(newGroup)
|
||||
.setRevision(revision)
|
||||
.build();
|
||||
}
|
||||
|
||||
private @NonNull GroupChange commitCancelChangeWithConflictResolution(@NonNull GroupChange.Actions.Builder change)
|
||||
throws GroupChangeFailedException, IOException, GroupLinkNotActiveException
|
||||
{
|
||||
int currentRevision = getCurrentGroupRevisionFromServer();
|
||||
|
||||
for (int attempt = 0; attempt < 5; attempt++) {
|
||||
try {
|
||||
GroupChange.Actions changeActions = change.setRevision(currentRevision + 1)
|
||||
.build();
|
||||
|
||||
Log.i(TAG, "Trying to cancel request group at V" + changeActions.getRevision());
|
||||
GroupChange signedGroupChange = commitJoinToServer(changeActions);
|
||||
|
||||
Log.i(TAG, "Successfully cancelled group join at V" + changeActions.getRevision());
|
||||
return signedGroupChange;
|
||||
} catch (GroupPatchNotAcceptedException e) {
|
||||
throw new GroupChangeFailedException(e);
|
||||
} catch (ConflictException e) {
|
||||
Log.w(TAG, "Revision conflict", e);
|
||||
|
||||
currentRevision = getCurrentGroupRevisionFromServer();
|
||||
}
|
||||
}
|
||||
|
||||
throw new GroupChangeFailedException("Unable to cancel group join request after conflicts");
|
||||
}
|
||||
}
|
||||
|
||||
private abstract static class LockOwner implements Closeable {
|
||||
final Closeable lock;
|
||||
|
||||
LockOwner(@NonNull Closeable lock) {
|
||||
this.lock = lock;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
|
@ -1,6 +1,6 @@
|
||||
package org.thoughtcrime.securesms.groups.ui;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.StringRes;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
@ -9,7 +9,11 @@ public final class GroupErrors {
|
||||
private GroupErrors() {
|
||||
}
|
||||
|
||||
public static @StringRes int getUserDisplayMessage(@NonNull GroupChangeFailureReason failureReason) {
|
||||
public static @StringRes int getUserDisplayMessage(@Nullable GroupChangeFailureReason failureReason) {
|
||||
if (failureReason == null) {
|
||||
return R.string.ManageGroupActivity_failed_to_update_the_group;
|
||||
}
|
||||
|
||||
switch (failureReason) {
|
||||
case NO_RIGHTS : return R.string.ManageGroupActivity_you_dont_have_the_rights_to_do_this;
|
||||
case NOT_CAPABLE : return R.string.ManageGroupActivity_not_capable;
|
||||
|
@ -1,42 +1,39 @@
|
||||
package org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.signal.storageservice.protos.groups.AccessControl;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroupJoinInfo;
|
||||
|
||||
public final class GroupDetails {
|
||||
private final String groupName;
|
||||
private final DecryptedGroupJoinInfo joinInfo;
|
||||
private final byte[] avatarBytes;
|
||||
private final int groupMembershipCount;
|
||||
private final boolean requiresAdminApproval;
|
||||
private final int groupRevision;
|
||||
|
||||
public GroupDetails(String groupName,
|
||||
byte[] avatarBytes,
|
||||
int groupMembershipCount,
|
||||
boolean requiresAdminApproval,
|
||||
int groupRevision)
|
||||
public GroupDetails(@NonNull DecryptedGroupJoinInfo joinInfo,
|
||||
@Nullable byte[] avatarBytes)
|
||||
{
|
||||
this.groupName = groupName;
|
||||
this.joinInfo = joinInfo;
|
||||
this.avatarBytes = avatarBytes;
|
||||
this.groupMembershipCount = groupMembershipCount;
|
||||
this.requiresAdminApproval = requiresAdminApproval;
|
||||
this.groupRevision = groupRevision;
|
||||
}
|
||||
|
||||
public String getGroupName() {
|
||||
return groupName;
|
||||
public @NonNull String getGroupName() {
|
||||
return joinInfo.getTitle();
|
||||
}
|
||||
|
||||
public byte[] getAvatarBytes() {
|
||||
public @Nullable byte[] getAvatarBytes() {
|
||||
return avatarBytes;
|
||||
}
|
||||
|
||||
public @NonNull DecryptedGroupJoinInfo getJoinInfo() {
|
||||
return joinInfo;
|
||||
}
|
||||
|
||||
public int getGroupMembershipCount() {
|
||||
return groupMembershipCount;
|
||||
return joinInfo.getMemberCount();
|
||||
}
|
||||
|
||||
public boolean joinRequiresAdminApproval() {
|
||||
return requiresAdminApproval;
|
||||
}
|
||||
|
||||
public int getGroupRevision() {
|
||||
return groupRevision;
|
||||
return joinInfo.getAddFromInviteLink() == AccessControl.AccessRequired.ADMINISTRATOR;
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
package org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
@ -22,7 +23,9 @@ import org.thoughtcrime.securesms.color.MaterialColor;
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView;
|
||||
import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto;
|
||||
import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationActivity;
|
||||
import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
@ -31,6 +34,8 @@ import org.thoughtcrime.securesms.util.ThemeUtil;
|
||||
|
||||
public final class GroupJoinBottomSheetDialogFragment extends BottomSheetDialogFragment {
|
||||
|
||||
private static final String TAG = Log.tag(GroupJoinUpdateRequiredBottomSheetDialogFragment.class);
|
||||
|
||||
private static final String ARG_GROUP_INVITE_LINK_URL = "group_invite_url";
|
||||
|
||||
private ProgressBar busy;
|
||||
@ -93,14 +98,13 @@ public final class GroupJoinBottomSheetDialogFragment extends BottomSheetDialogF
|
||||
groupName.setText(details.getGroupName());
|
||||
groupDetails.setText(requireContext().getResources().getQuantityString(R.plurals.GroupJoinBottomSheetDialogFragment_group_dot_d_members, details.getGroupMembershipCount(), details.getGroupMembershipCount()));
|
||||
|
||||
switch (FeatureFlags.clientLocalGroupJoinStatus()) {
|
||||
switch (getGroupJoinStatus()) {
|
||||
case COMING_SOON:
|
||||
groupJoinExplain.setText(R.string.GroupJoinUpdateRequiredBottomSheetDialogFragment_coming_soon);
|
||||
groupCancelButton.setText(android.R.string.ok);
|
||||
groupJoinButton.setVisibility(View.GONE);
|
||||
break;
|
||||
case UPDATE_TO_JOIN:
|
||||
case LOCAL_CAN_JOIN:
|
||||
groupJoinExplain.setText(R.string.GroupJoinUpdateRequiredBottomSheetDialogFragment_update_message);
|
||||
groupJoinButton.setText(R.string.GroupJoinUpdateRequiredBottomSheetDialogFragment_update_signal);
|
||||
groupJoinButton.setOnClickListener(v -> {
|
||||
@ -109,6 +113,17 @@ public final class GroupJoinBottomSheetDialogFragment extends BottomSheetDialogF
|
||||
});
|
||||
groupJoinButton.setVisibility(View.VISIBLE);
|
||||
break;
|
||||
case LOCAL_CAN_JOIN:
|
||||
groupJoinExplain.setText(details.joinRequiresAdminApproval() ? R.string.GroupJoinBottomSheetDialogFragment_admin_approval_needed
|
||||
: R.string.GroupJoinBottomSheetDialogFragment_direct_join);
|
||||
groupJoinButton.setText(details.joinRequiresAdminApproval() ? R.string.GroupJoinBottomSheetDialogFragment_request_to_join
|
||||
: R.string.GroupJoinBottomSheetDialogFragment_join);
|
||||
groupJoinButton.setOnClickListener(v -> {
|
||||
Log.i(TAG, details.joinRequiresAdminApproval() ? "Attempting to direct join group" : "Attempting to request to join group");
|
||||
viewModel.join(details);
|
||||
});
|
||||
groupJoinButton.setVisibility(View.VISIBLE);
|
||||
break;
|
||||
}
|
||||
|
||||
avatar.setImageBytesForGroup(details.getAvatarBytes(), new FallbackPhotoProvider(), MaterialColor.STEEL);
|
||||
@ -117,19 +132,55 @@ public final class GroupJoinBottomSheetDialogFragment extends BottomSheetDialogF
|
||||
});
|
||||
|
||||
viewModel.isBusy().observe(getViewLifecycleOwner(), isBusy -> busy.setVisibility(isBusy ? View.VISIBLE : View.GONE));
|
||||
|
||||
viewModel.getErrors().observe(getViewLifecycleOwner(), error -> {
|
||||
Toast.makeText(requireContext(), errorToMessage(error), Toast.LENGTH_SHORT).show();
|
||||
dismiss();
|
||||
});
|
||||
|
||||
viewModel.getJoinErrors().observe(getViewLifecycleOwner(), error -> Toast.makeText(requireContext(), errorToMessage(error), Toast.LENGTH_SHORT).show());
|
||||
|
||||
viewModel.getJoinSuccess().observe(getViewLifecycleOwner(), joinGroupSuccess -> {
|
||||
Log.i(TAG, "Group joined, navigating to group");
|
||||
|
||||
Intent intent = ConversationActivity.buildIntent(requireContext(), joinGroupSuccess.getGroupRecipient().getId(), joinGroupSuccess.getGroupThreadId());
|
||||
requireActivity().startActivity(intent);
|
||||
|
||||
dismiss();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
protected @NonNull String errorToMessage(FetchGroupDetailsError error) {
|
||||
private static FeatureFlags.GroupJoinStatus getGroupJoinStatus() {
|
||||
FeatureFlags.GroupJoinStatus groupJoinStatus = FeatureFlags.clientLocalGroupJoinStatus();
|
||||
|
||||
if (groupJoinStatus == FeatureFlags.GroupJoinStatus.LOCAL_CAN_JOIN) {
|
||||
if (!FeatureFlags.groupsV2() || Recipient.self().getGroupsV2Capability() == Recipient.Capability.NOT_SUPPORTED) {
|
||||
// TODO [Alan] GV2 additional copy could be presented in these cases
|
||||
return FeatureFlags.GroupJoinStatus.UPDATE_TO_JOIN;
|
||||
}
|
||||
|
||||
return groupJoinStatus;
|
||||
}
|
||||
|
||||
return groupJoinStatus;
|
||||
}
|
||||
|
||||
private @NonNull String errorToMessage(@NonNull FetchGroupDetailsError error) {
|
||||
if (error == FetchGroupDetailsError.GroupLinkNotActive) {
|
||||
return getString(R.string.GroupJoinBottomSheetDialogFragment_this_group_link_is_not_active);
|
||||
}
|
||||
return getString(R.string.GroupJoinBottomSheetDialogFragment_unable_to_get_group_information_please_try_again_later);
|
||||
}
|
||||
|
||||
private @NonNull String errorToMessage(@NonNull JoinGroupError error) {
|
||||
switch (error) {
|
||||
case GROUP_LINK_NOT_ACTIVE: return getString(R.string.GroupJoinBottomSheetDialogFragment_this_group_link_is_not_active);
|
||||
case NETWORK_ERROR : return getString(R.string.GroupJoinBottomSheetDialogFragment_encountered_a_network_error);
|
||||
default : return getString(R.string.GroupJoinBottomSheetDialogFragment_unable_to_join_group_please_try_again_later);
|
||||
}
|
||||
}
|
||||
|
||||
private GroupInviteLinkUrl getGroupInviteLinkUrl() {
|
||||
try {
|
||||
//noinspection ConstantConditions
|
||||
|
@ -5,15 +5,17 @@ import android.content.Context;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
import androidx.core.util.Consumer;
|
||||
|
||||
import org.signal.storageservice.protos.groups.AccessControl;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroupJoinInfo;
|
||||
import org.signal.zkgroup.VerificationFailedException;
|
||||
import org.thoughtcrime.securesms.groups.GroupChangeBusyException;
|
||||
import org.thoughtcrime.securesms.groups.GroupChangeFailedException;
|
||||
import org.thoughtcrime.securesms.groups.GroupManager;
|
||||
import org.thoughtcrime.securesms.groups.MembershipNotSuitableForV2Exception;
|
||||
import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl;
|
||||
import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.util.AsynchronousCallback;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException;
|
||||
|
||||
@ -31,7 +33,7 @@ final class GroupJoinRepository {
|
||||
this.groupInviteLinkUrl = groupInviteLinkUrl;
|
||||
}
|
||||
|
||||
void getGroupDetails(@NonNull GetGroupDetailsCallback callback) {
|
||||
void getGroupDetails(@NonNull AsynchronousCallback.WorkerThread<GroupDetails, FetchGroupDetailsError> callback) {
|
||||
SignalExecutors.UNBOUNDED.execute(() -> {
|
||||
try {
|
||||
callback.onComplete(getGroupDetails());
|
||||
@ -43,6 +45,30 @@ final class GroupJoinRepository {
|
||||
});
|
||||
}
|
||||
|
||||
void joinGroup(@NonNull GroupDetails groupDetails,
|
||||
@NonNull AsynchronousCallback.WorkerThread<JoinGroupSuccess, JoinGroupError> callback)
|
||||
{
|
||||
SignalExecutors.UNBOUNDED.execute(() -> {
|
||||
try {
|
||||
GroupManager.GroupActionResult groupActionResult = GroupManager.joinGroup(context,
|
||||
groupInviteLinkUrl.getGroupMasterKey(),
|
||||
groupInviteLinkUrl.getPassword(),
|
||||
groupDetails.getJoinInfo(),
|
||||
groupDetails.getAvatarBytes());
|
||||
|
||||
callback.onComplete(new JoinGroupSuccess(groupActionResult.getGroupRecipient(), groupActionResult.getThreadId()));
|
||||
} catch (IOException e) {
|
||||
callback.onError(JoinGroupError.NETWORK_ERROR);
|
||||
} catch (GroupChangeBusyException e) {
|
||||
callback.onError(JoinGroupError.BUSY);
|
||||
} catch (GroupLinkNotActiveException e) {
|
||||
callback.onError(JoinGroupError.GROUP_LINK_NOT_ACTIVE);
|
||||
} catch (GroupChangeFailedException | MembershipNotSuitableForV2Exception e) {
|
||||
callback.onError(JoinGroupError.FAILED);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private @NonNull GroupDetails getGroupDetails()
|
||||
throws VerificationFailedException, IOException, GroupLinkNotActiveException
|
||||
@ -52,13 +78,8 @@ final class GroupJoinRepository {
|
||||
groupInviteLinkUrl.getPassword());
|
||||
|
||||
byte[] avatarBytes = tryGetAvatarBytes(joinInfo);
|
||||
boolean requiresAdminApproval = joinInfo.getAddFromInviteLink() == AccessControl.AccessRequired.ADMINISTRATOR;
|
||||
|
||||
return new GroupDetails(joinInfo.getTitle(),
|
||||
avatarBytes,
|
||||
joinInfo.getMemberCount(),
|
||||
requiresAdminApproval,
|
||||
joinInfo.getRevision());
|
||||
return new GroupDetails(joinInfo, avatarBytes);
|
||||
}
|
||||
|
||||
private @Nullable byte[] tryGetAvatarBytes(@NonNull DecryptedGroupJoinInfo joinInfo) {
|
||||
@ -69,9 +90,4 @@ final class GroupJoinRepository {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface GetGroupDetailsCallback {
|
||||
void onComplete(@NonNull GroupDetails groupDetails);
|
||||
void onError(@NonNull FetchGroupDetailsError error);
|
||||
}
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining;
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MediatorLiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
@ -10,35 +11,62 @@ import androidx.lifecycle.ViewModel;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
|
||||
import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl;
|
||||
import org.thoughtcrime.securesms.util.AsynchronousCallback;
|
||||
import org.thoughtcrime.securesms.util.SingleLiveEvent;
|
||||
|
||||
public class GroupJoinViewModel extends ViewModel {
|
||||
|
||||
private final GroupJoinRepository repository;
|
||||
private final MutableLiveData<GroupDetails> groupDetails = new MutableLiveData<>();
|
||||
private final MutableLiveData<FetchGroupDetailsError> errors = new SingleLiveEvent<>();
|
||||
private final MutableLiveData<JoinGroupError> joinErrors = new SingleLiveEvent<>();
|
||||
private final MutableLiveData<Boolean> busy = new MediatorLiveData<>();
|
||||
private final MutableLiveData<JoinGroupSuccess> joinSuccess = new SingleLiveEvent<>();
|
||||
|
||||
private GroupJoinViewModel(@NonNull GroupJoinRepository repository) {
|
||||
this.repository = repository;
|
||||
|
||||
busy.setValue(true);
|
||||
repository.getGroupDetails(new GroupJoinRepository.GetGroupDetailsCallback() {
|
||||
repository.getGroupDetails(new AsynchronousCallback.WorkerThread<GroupDetails, FetchGroupDetailsError>() {
|
||||
@Override
|
||||
public void onComplete(@NonNull GroupDetails details) {
|
||||
public void onComplete(@Nullable GroupDetails details) {
|
||||
busy.postValue(false);
|
||||
groupDetails.postValue(details);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(@NonNull FetchGroupDetailsError error) {
|
||||
public void onError(@Nullable FetchGroupDetailsError error) {
|
||||
busy.postValue(false);
|
||||
errors.postValue(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void join(@NonNull GroupDetails groupDetails) {
|
||||
busy.setValue(true);
|
||||
repository.joinGroup(groupDetails, new AsynchronousCallback.WorkerThread<JoinGroupSuccess, JoinGroupError>() {
|
||||
@Override
|
||||
public void onComplete(@Nullable JoinGroupSuccess result) {
|
||||
busy.postValue(false);
|
||||
joinSuccess.postValue(result);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(@Nullable JoinGroupError error) {
|
||||
busy.postValue(false);
|
||||
joinErrors.postValue(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
LiveData<GroupDetails> getGroupDetails() {
|
||||
return groupDetails;
|
||||
}
|
||||
|
||||
LiveData<JoinGroupSuccess> getJoinSuccess() {
|
||||
return joinSuccess;
|
||||
}
|
||||
|
||||
LiveData<Boolean> isBusy() {
|
||||
return busy;
|
||||
}
|
||||
@ -47,6 +75,10 @@ public class GroupJoinViewModel extends ViewModel {
|
||||
return errors;
|
||||
}
|
||||
|
||||
LiveData<JoinGroupError> getJoinErrors() {
|
||||
return joinErrors;
|
||||
}
|
||||
|
||||
public static class Factory implements ViewModelProvider.Factory {
|
||||
|
||||
private final Context context;
|
||||
|
@ -0,0 +1,8 @@
|
||||
package org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining;
|
||||
|
||||
enum JoinGroupError {
|
||||
BUSY,
|
||||
GROUP_LINK_NOT_ACTIVE,
|
||||
FAILED,
|
||||
NETWORK_ERROR,
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
package org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining;
|
||||
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
|
||||
final class JoinGroupSuccess {
|
||||
private final Recipient groupRecipient;
|
||||
private final long groupThreadId;
|
||||
|
||||
JoinGroupSuccess(Recipient groupRecipient, long groupThreadId) {
|
||||
this.groupRecipient = groupRecipient;
|
||||
this.groupThreadId = groupThreadId;
|
||||
}
|
||||
|
||||
Recipient getGroupRecipient() {
|
||||
return groupRecipient;
|
||||
}
|
||||
|
||||
long getGroupThreadId() {
|
||||
return groupThreadId;
|
||||
}
|
||||
}
|
@ -1,25 +1,28 @@
|
||||
package org.thoughtcrime.securesms.groups.v2.processing;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil;
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupChangeReconstruct;
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupChangeUtil;
|
||||
import org.whispersystems.signalservice.api.groupsv2.NotAbleToApplyGroupV2ChangeException;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.Objects;
|
||||
import java.util.List;
|
||||
|
||||
final class GroupStateMapper {
|
||||
|
||||
private static final String TAG = Log.tag(GroupStateMapper.class);
|
||||
|
||||
static final int LATEST = Integer.MAX_VALUE;
|
||||
static final int PLACEHOLDER_REVISION = -1;
|
||||
|
||||
private static final Comparator<ServerGroupLogEntry> BY_REVISION = (o1, o2) -> Integer.compare(o1.getRevision(), o2.getRevision());
|
||||
|
||||
@ -36,10 +39,18 @@ final class GroupStateMapper {
|
||||
static @NonNull AdvanceGroupStateResult partiallyAdvanceGroupState(@NonNull GlobalGroupState inputState,
|
||||
int maximumRevisionToApply)
|
||||
{
|
||||
ArrayList<LocalGroupLogEntry> appliedChanges = new ArrayList<>(inputState.getServerHistory().size());
|
||||
AdvanceGroupStateResult groupStateResult = processChanges(inputState, maximumRevisionToApply);
|
||||
|
||||
return cleanDuplicatedChanges(groupStateResult, inputState.getLocalState());
|
||||
}
|
||||
|
||||
private static @NonNull AdvanceGroupStateResult processChanges(@NonNull GlobalGroupState inputState,
|
||||
int maximumRevisionToApply)
|
||||
{
|
||||
HashMap<Integer, ServerGroupLogEntry> statesToApplyNow = new HashMap<>(inputState.getServerHistory().size());
|
||||
ArrayList<ServerGroupLogEntry> statesToApplyLater = new ArrayList<>(inputState.getServerHistory().size());
|
||||
DecryptedGroup current = inputState.getLocalState();
|
||||
StateChain<DecryptedGroup, DecryptedGroupChange> stateChain = createNewMapper();
|
||||
|
||||
if (inputState.getServerHistory().isEmpty()) {
|
||||
return new AdvanceGroupStateResult(Collections.emptyList(), new GlobalGroupState(current, Collections.emptyList()));
|
||||
@ -55,9 +66,15 @@ final class GroupStateMapper {
|
||||
|
||||
Collections.sort(statesToApplyLater, BY_REVISION);
|
||||
|
||||
final int from = inputState.getEarliestRevisionNumber();
|
||||
final int from = Math.max(0, inputState.getEarliestRevisionNumber());
|
||||
final int to = Math.min(inputState.getLatestRevisionNumber(), maximumRevisionToApply);
|
||||
|
||||
if (current != null && current.getRevision() == PLACEHOLDER_REVISION) {
|
||||
Log.i(TAG, "Ignoring place holder group state");
|
||||
} else {
|
||||
stateChain.push(current, null);
|
||||
}
|
||||
|
||||
for (int revision = from; revision >= 0 && revision <= to; revision++) {
|
||||
ServerGroupLogEntry entry = statesToApplyNow.get(revision);
|
||||
if (entry == null) {
|
||||
@ -65,59 +82,64 @@ final class GroupStateMapper {
|
||||
continue;
|
||||
}
|
||||
|
||||
DecryptedGroup groupAtRevision = entry.getGroup();
|
||||
DecryptedGroupChange changeAtRevision = entry.getChange();
|
||||
if (stateChain.getLatestState() == null && entry.getGroup() != null && current != null && current.getRevision() == PLACEHOLDER_REVISION) {
|
||||
DecryptedGroup previousState = DecryptedGroup.newBuilder(entry.getGroup())
|
||||
.setTitle(current.getTitle())
|
||||
.setAvatar(current.getAvatar())
|
||||
.build();
|
||||
|
||||
if (current == null) {
|
||||
Log.w(TAG, "No local state, accepting server state for V" + revision);
|
||||
current = groupAtRevision;
|
||||
if (groupAtRevision != null) {
|
||||
appliedChanges.add(new LocalGroupLogEntry(groupAtRevision, changeAtRevision));
|
||||
}
|
||||
continue;
|
||||
stateChain.push(previousState, null);
|
||||
}
|
||||
|
||||
if (current.getRevision() + 1 != revision) {
|
||||
Log.w(TAG, "Detected gap V" + revision);
|
||||
stateChain.push(entry.getGroup(), entry.getChange());
|
||||
}
|
||||
|
||||
if (changeAtRevision == null) {
|
||||
Log.w(TAG, "Reconstructing change for V" + revision);
|
||||
changeAtRevision = GroupChangeReconstruct.reconstructGroupChange(current, Objects.requireNonNull(groupAtRevision));
|
||||
List<StateChain.Pair<DecryptedGroup, DecryptedGroupChange>> mapperList = stateChain.getList();
|
||||
List<LocalGroupLogEntry> appliedChanges = new ArrayList<>(mapperList.size());
|
||||
|
||||
for (StateChain.Pair<DecryptedGroup, DecryptedGroupChange> entry : mapperList) {
|
||||
if (current == null || entry.getDelta() != null) {
|
||||
appliedChanges.add(new LocalGroupLogEntry(entry.getState(), entry.getDelta()));
|
||||
}
|
||||
}
|
||||
|
||||
DecryptedGroup groupWithChangeApplied;
|
||||
return new AdvanceGroupStateResult(appliedChanges, new GlobalGroupState(stateChain.getLatestState(), statesToApplyLater));
|
||||
}
|
||||
|
||||
private static AdvanceGroupStateResult cleanDuplicatedChanges(@NonNull AdvanceGroupStateResult groupStateResult,
|
||||
@Nullable DecryptedGroup previousGroupState)
|
||||
{
|
||||
if (previousGroupState == null) return groupStateResult;
|
||||
|
||||
ArrayList<LocalGroupLogEntry> appliedChanges = new ArrayList<>(groupStateResult.getProcessedLogEntries().size());
|
||||
|
||||
for (LocalGroupLogEntry entry : groupStateResult.getProcessedLogEntries()) {
|
||||
DecryptedGroupChange change = entry.getChange();
|
||||
|
||||
if (change != null) {
|
||||
change = GroupChangeUtil.resolveConflict(previousGroupState, change).build();
|
||||
}
|
||||
|
||||
appliedChanges.add(new LocalGroupLogEntry(entry.getGroup(), change));
|
||||
|
||||
previousGroupState = entry.getGroup();
|
||||
}
|
||||
|
||||
return new AdvanceGroupStateResult(appliedChanges, groupStateResult.getNewGlobalGroupState());
|
||||
}
|
||||
|
||||
private static StateChain<DecryptedGroup, DecryptedGroupChange> createNewMapper() {
|
||||
return new StateChain<>(
|
||||
(group, change) -> {
|
||||
try {
|
||||
groupWithChangeApplied = DecryptedGroupUtil.applyWithoutRevisionCheck(current, changeAtRevision);
|
||||
return DecryptedGroupUtil.applyWithoutRevisionCheck(group, change);
|
||||
} catch (NotAbleToApplyGroupV2ChangeException e) {
|
||||
Log.w(TAG, "Unable to apply V" + revision, e);
|
||||
continue;
|
||||
Log.w(TAG, "Unable to apply V" + change.getRevision(), e);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (groupAtRevision == null) {
|
||||
Log.w(TAG, "Reconstructing state for V" + revision);
|
||||
groupAtRevision = groupWithChangeApplied;
|
||||
}
|
||||
|
||||
if (current.getRevision() != groupAtRevision.getRevision()) {
|
||||
appliedChanges.add(new LocalGroupLogEntry(groupAtRevision, changeAtRevision));
|
||||
} else {
|
||||
DecryptedGroupChange sameRevisionDelta = GroupChangeReconstruct.reconstructGroupChange(current, groupAtRevision);
|
||||
if (!DecryptedGroupUtil.changeIsEmpty(sameRevisionDelta)) {
|
||||
appliedChanges.add(new LocalGroupLogEntry(groupAtRevision, sameRevisionDelta));
|
||||
Log.w(TAG, "Inserted repair change for mismatch V" + revision);
|
||||
}
|
||||
}
|
||||
|
||||
DecryptedGroupChange missingChanges = GroupChangeReconstruct.reconstructGroupChange(groupWithChangeApplied, groupAtRevision);
|
||||
if (!DecryptedGroupUtil.changeIsEmpty(missingChanges)) {
|
||||
appliedChanges.add(new LocalGroupLogEntry(groupAtRevision, missingChanges));
|
||||
Log.w(TAG, "Inserted repair change for gap V" + revision);
|
||||
}
|
||||
|
||||
current = groupAtRevision;
|
||||
}
|
||||
|
||||
return new AdvanceGroupStateResult(appliedChanges, new GlobalGroupState(current, statesToApplyLater));
|
||||
},
|
||||
(groupB, groupA) -> GroupChangeReconstruct.reconstructGroupChange(groupA, groupB),
|
||||
(groupA, groupB) -> DecryptedGroupUtil.changeIsEmpty(GroupChangeReconstruct.reconstructGroupChange(groupA, groupB))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -17,9 +17,7 @@ import org.signal.zkgroup.groups.GroupSecretParams;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase;
|
||||
import org.thoughtcrime.securesms.database.MessageDatabase;
|
||||
import org.thoughtcrime.securesms.database.MmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
||||
import org.thoughtcrime.securesms.database.SmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.groups.GroupId;
|
||||
@ -66,6 +64,7 @@ public final class GroupsV2StateProcessor {
|
||||
private static final String TAG = Log.tag(GroupsV2StateProcessor.class);
|
||||
|
||||
public static final int LATEST = GroupStateMapper.LATEST;
|
||||
public static final int PLACEHOLDER_REVISION = GroupStateMapper.PLACEHOLDER_REVISION;
|
||||
|
||||
private final Context context;
|
||||
private final JobManager jobManager;
|
||||
@ -177,10 +176,27 @@ public final class GroupsV2StateProcessor {
|
||||
try {
|
||||
inputGroupState = queryServer(localState, revision == LATEST && localState == null);
|
||||
} catch (GroupNotAMemberException e) {
|
||||
if (localState != null && signedGroupChange != null) {
|
||||
try {
|
||||
Log.i(TAG, "Applying P2P group change when not a member");
|
||||
DecryptedGroup newState = DecryptedGroupUtil.applyWithoutRevisionCheck(localState, signedGroupChange);
|
||||
|
||||
inputGroupState = new GlobalGroupState(localState, Collections.singletonList(new ServerGroupLogEntry(newState, signedGroupChange)));
|
||||
} catch (NotAbleToApplyGroupV2ChangeException failed) {
|
||||
Log.w(TAG, "Unable to apply P2P group change when not a member", failed);
|
||||
}
|
||||
}
|
||||
|
||||
if (inputGroupState == null) {
|
||||
if (localState != null && DecryptedGroupUtil.isPendingOrRequesting(localState, Recipient.self().getUuid().get())) {
|
||||
Log.w(TAG, "Unable to query server for group " + groupId + " server says we're not in group, but we think we are a pending or requesting member");
|
||||
} else {
|
||||
Log.w(TAG, "Unable to query server for group " + groupId + " server says we're not in group, inserting leave message");
|
||||
insertGroupLeave();
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.i(TAG, "Saved server query for group change");
|
||||
}
|
||||
|
@ -0,0 +1,172 @@
|
||||
package org.thoughtcrime.securesms.groups.v2.processing;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Maintains a chain of state pairs:
|
||||
* <pre>
|
||||
* {@code
|
||||
* (S1, Delta1),
|
||||
* (S2, Delta2),
|
||||
* (S3, Delta3)
|
||||
* }
|
||||
* </pre>
|
||||
* Such that the states always include all deltas.
|
||||
* <pre>
|
||||
* {@code
|
||||
* (S1, _),
|
||||
* (S1 + Delta2, Delta2),
|
||||
* (S1 + Delta2 + Delta3, Delta3),
|
||||
* }
|
||||
* </pre>
|
||||
* <p>
|
||||
* If a pushed delta does not correct create the new state (tested by {@link StateEquality}), a new
|
||||
* delta and state is inserted like so:
|
||||
* <pre>
|
||||
* {@code
|
||||
* (PreviousState, PreviousDelta),
|
||||
* (PreviousState + NewDelta, NewDelta),
|
||||
* (NewState, PreviousState + NewDelta - NewState),
|
||||
* }
|
||||
* </pre>
|
||||
* That is it keeps both the newly supplied delta and state, but creates an interim state and delta.
|
||||
*
|
||||
* The + function is supplied by {@link AddDelta} and the - function is supplied by {@link SubtractStates}.
|
||||
*/
|
||||
public final class StateChain<State, Delta> {
|
||||
|
||||
private final AddDelta<State, Delta> add;
|
||||
private final SubtractStates<State, Delta> subtract;
|
||||
private final StateEquality<State> stateEquality;
|
||||
|
||||
private final List<Pair<State, Delta>> pairs = new LinkedList<>();
|
||||
|
||||
public StateChain(@NonNull AddDelta<State, Delta> add,
|
||||
@NonNull SubtractStates<State, Delta> subtract,
|
||||
@NonNull StateEquality<State> stateEquality)
|
||||
{
|
||||
this.add = add;
|
||||
this.subtract = subtract;
|
||||
this.stateEquality = stateEquality;
|
||||
}
|
||||
|
||||
public void push(@Nullable State state, @Nullable Delta delta) {
|
||||
if (delta == null && state == null) return;
|
||||
|
||||
boolean bothSupplied = state != null && delta != null;
|
||||
State latestState = getLatestState();
|
||||
|
||||
if (latestState == null && state == null) return;
|
||||
|
||||
if (latestState != null) {
|
||||
if (delta == null) {
|
||||
|
||||
delta = subtract.subtract(state, latestState);
|
||||
}
|
||||
|
||||
if (state == null) {
|
||||
state = add.add(latestState, delta);
|
||||
|
||||
if (state == null) return;
|
||||
}
|
||||
|
||||
if (bothSupplied) {
|
||||
State calculatedState = add.add(latestState, delta);
|
||||
|
||||
if (calculatedState == null) {
|
||||
push(state, null);
|
||||
return;
|
||||
} else if (!stateEquality.equals(state, calculatedState)) {
|
||||
push(null, delta);
|
||||
push(state, null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (latestState == null || !stateEquality.equals(latestState, state)) {
|
||||
pairs.add(new Pair<>(state, delta));
|
||||
}
|
||||
}
|
||||
|
||||
public @Nullable State getLatestState() {
|
||||
int size = pairs.size();
|
||||
|
||||
return size == 0 ? null : pairs.get(size - 1).getState();
|
||||
}
|
||||
|
||||
public List<Pair<State, Delta>> getList() {
|
||||
return new ArrayList<>(pairs);
|
||||
}
|
||||
|
||||
public static final class Pair<State, Delta> {
|
||||
@NonNull private final State state;
|
||||
@Nullable private final Delta delta;
|
||||
|
||||
Pair(@NonNull State state, @Nullable Delta delta) {
|
||||
this.state = state;
|
||||
this.delta = delta;
|
||||
}
|
||||
|
||||
public @NonNull State getState() {
|
||||
return state;
|
||||
}
|
||||
|
||||
public @Nullable Delta getDelta() {
|
||||
return delta;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return String.format("(%s, %s)", state, delta);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
|
||||
Pair<?, ?> other = (Pair<?, ?>) o;
|
||||
|
||||
return state.equals(other.state) &&
|
||||
Objects.equals(delta, other.delta);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
int result = state.hashCode();
|
||||
result = 31 * result + (delta != null ? delta.hashCode() : 0);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
interface AddDelta<State, Delta> {
|
||||
|
||||
/**
|
||||
* Add {@param delta} to {@param state} and return the new {@link State}.
|
||||
* <p>
|
||||
* If this returns null, then the delta could not be applied and will be ignored.
|
||||
*/
|
||||
@Nullable State add(@NonNull State state, @NonNull Delta delta);
|
||||
}
|
||||
|
||||
interface SubtractStates<State, Delta> {
|
||||
|
||||
/**
|
||||
* Finds a delta = {@param stateB} - {@param stateA}
|
||||
* such that {@param stateA} + {@link Delta} = {@param stateB}.
|
||||
*/
|
||||
@NonNull Delta subtract(@NonNull State stateB, @NonNull State stateA);
|
||||
}
|
||||
|
||||
interface StateEquality<State> {
|
||||
|
||||
boolean equals(@NonNull State stateA, @NonNull State stateB);
|
||||
}
|
||||
}
|
@ -28,7 +28,6 @@ import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
|
||||
import org.thoughtcrime.securesms.crypto.SecurityEvent;
|
||||
import org.thoughtcrime.securesms.crypto.storage.TextSecureSessionStore;
|
||||
import org.thoughtcrime.securesms.database.AttachmentDatabase;
|
||||
import org.thoughtcrime.securesms.database.Database;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase;
|
||||
import org.thoughtcrime.securesms.database.GroupReceiptDatabase;
|
||||
@ -36,10 +35,8 @@ import org.thoughtcrime.securesms.database.GroupReceiptDatabase.GroupReceiptInfo
|
||||
import org.thoughtcrime.securesms.database.MessageDatabase;
|
||||
import org.thoughtcrime.securesms.database.MessageDatabase.InsertResult;
|
||||
import org.thoughtcrime.securesms.database.MessageDatabase.SyncMessageId;
|
||||
import org.thoughtcrime.securesms.database.MmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.MmsSmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
||||
import org.thoughtcrime.securesms.database.SmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.StickerDatabase;
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.Mention;
|
||||
@ -350,16 +347,16 @@ public final class PushProcessMessageJob extends BaseJob {
|
||||
|
||||
if (isGv2Message) {
|
||||
GroupMasterKey groupMasterKey = message.getGroupContext().get().getGroupV2().get().getMasterKey();
|
||||
GroupId.V2 groupIdV2 = groupId.get().requireV2();
|
||||
|
||||
if (!groupV2PreProcessMessage(content, groupMasterKey, message.getGroupContext().get().getGroupV2().get())) {
|
||||
Log.i(TAG, "Ignoring GV2 message for group we are not currently in " + groupId);
|
||||
Log.i(TAG, "Ignoring GV2 message for group we are not currently in " + groupIdV2);
|
||||
return;
|
||||
}
|
||||
|
||||
GroupId.V2 groupIdV2 = groupId.get().requireV2();
|
||||
Recipient sender = Recipient.externalPush(context, content.getSender());
|
||||
if (!groupDatabase.isCurrentMember(groupIdV2, sender.getId())) {
|
||||
Log.i(TAG, "Ignoring GV2 message from member not in group " + groupId);
|
||||
Log.i(TAG, "Ignoring GV2 message from member not in group " + groupIdV2);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
@ -7,7 +7,6 @@ import org.signal.zkgroup.groups.GroupMasterKey;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase;
|
||||
import org.thoughtcrime.securesms.database.MessageDatabase;
|
||||
import org.thoughtcrime.securesms.database.MmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context;
|
||||
import org.thoughtcrime.securesms.groups.GroupChangeBusyException;
|
||||
import org.thoughtcrime.securesms.groups.GroupId;
|
||||
@ -22,7 +21,6 @@ import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.mms.MmsException;
|
||||
import org.thoughtcrime.securesms.mms.OutgoingGroupUpdateMessage;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.Hex;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.groupsv2.NoCredentialForRedemptionTimeException;
|
||||
@ -79,11 +77,12 @@ public final class WakeGroupV2Job extends BaseJob {
|
||||
GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context);
|
||||
GroupId.V2 groupId = GroupId.v2(groupMasterKey);
|
||||
|
||||
if (!groupDatabase.getGroup(groupId).isPresent()) {
|
||||
if (groupDatabase.findGroup(groupId)) {
|
||||
Log.w(TAG, "Group already exists " + groupId);
|
||||
return;
|
||||
} else {
|
||||
GroupManager.updateGroupFromServer(context, groupMasterKey, GroupsV2StateProcessor.LATEST, System.currentTimeMillis(), null);
|
||||
Log.i(TAG, "Group created " + groupId);
|
||||
} else {
|
||||
Log.w(TAG, "Group already exists " + groupId);
|
||||
}
|
||||
|
||||
Optional<GroupDatabase.GroupRecord> group = groupDatabase.getGroup(groupId);
|
||||
|
@ -0,0 +1,64 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
public final class AsynchronousCallback {
|
||||
|
||||
/**
|
||||
* Use to call back from a asynchronous repository call, e.g. a load operation.
|
||||
* <p>
|
||||
* Using the original thread used for operation to invoke the callback methods.
|
||||
* <p>
|
||||
* The contract is that exactly one method on the callback will be called, exactly once.
|
||||
*
|
||||
* @param <R> Result type
|
||||
* @param <E> Error type
|
||||
*/
|
||||
public interface WorkerThread<R, E> {
|
||||
|
||||
@androidx.annotation.WorkerThread
|
||||
void onComplete(@Nullable R result);
|
||||
|
||||
@androidx.annotation.WorkerThread
|
||||
void onError(@Nullable E error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Use to call back from a asynchronous repository call, e.g. a load operation.
|
||||
* <p>
|
||||
* Using the main thread used for operation to invoke the callback methods.
|
||||
* <p>
|
||||
* The contract is that exactly one method on the callback will be called, exactly once.
|
||||
*
|
||||
* @param <R> Result type
|
||||
* @param <E> Error type
|
||||
*/
|
||||
public interface MainThread<R, E> {
|
||||
|
||||
@androidx.annotation.MainThread
|
||||
void onComplete(@Nullable R result);
|
||||
|
||||
@androidx.annotation.MainThread
|
||||
void onError(@Nullable E error);
|
||||
|
||||
|
||||
/**
|
||||
* If you have a callback that is only suitable for running on the main thread, this will
|
||||
* decorate it to make it suitable to pass as a worker thread callback.
|
||||
*/
|
||||
default @NonNull WorkerThread<R, E> toWorkerCallback() {
|
||||
return new WorkerThread<R, E>() {
|
||||
@Override
|
||||
public void onComplete(@Nullable R result) {
|
||||
Util.runOnMain(() -> MainThread.this.onComplete(result));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(@Nullable E error) {
|
||||
Util.runOnMain(() -> MainThread.this.onError(error));
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
@ -112,9 +112,7 @@ public class CommunicationActions {
|
||||
|
||||
@Override
|
||||
protected void onPostExecute(Long threadId) {
|
||||
Intent intent = new Intent(context, ConversationActivity.class);
|
||||
intent.putExtra(ConversationActivity.RECIPIENT_EXTRA, recipient.getId());
|
||||
intent.putExtra(ConversationActivity.THREAD_ID_EXTRA, threadId);
|
||||
Intent intent = ConversationActivity.buildIntent(context, recipient.getId(), threadId);
|
||||
|
||||
if (!TextUtils.isEmpty(text)) {
|
||||
intent.putExtra(ConversationActivity.TEXT_EXTRA, text);
|
||||
|
@ -1,6 +1,7 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
|
||||
/**
|
||||
* A class that will throttle the number of runnables executed to be at most once every specified
|
||||
@ -21,7 +22,7 @@ public class Debouncer {
|
||||
* {@code threshold} milliseconds.
|
||||
*/
|
||||
public Debouncer(long threshold) {
|
||||
this.handler = new Handler();
|
||||
this.handler = new Handler(Looper.getMainLooper());
|
||||
this.threshold = threshold;
|
||||
}
|
||||
|
||||
|
@ -99,6 +99,10 @@
|
||||
layout="@layout/conversation_no_longer_a_member"
|
||||
android:visibility="gone" />
|
||||
|
||||
<include
|
||||
layout="@layout/conversation_requesting_bottom_banner"
|
||||
android:visibility="gone" />
|
||||
|
||||
<include layout="@layout/conversation_input_panel" />
|
||||
|
||||
<include layout="@layout/conversation_search_nav" />
|
||||
|
@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/conversation_requesting_banner"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?secondary_background"
|
||||
android:gravity="center"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingEnd="16dp"
|
||||
android:paddingBottom="8dp"
|
||||
android:text="@string/ConversationActivity_your_request_to_join_has_been_sent_to_the_group_admin"
|
||||
android:textColor="?title_text_color_secondary" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/conversation_cancel_request"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:text="@string/ConversationActivity_cancel_request" />
|
||||
|
||||
</LinearLayout>
|
@ -245,6 +245,8 @@
|
||||
<string name="ConversationActivity_unable_to_record_audio">Unable to record audio!</string>
|
||||
<string name="ConversationActivity_you_cant_send_messages_to_this_group">You can\'t send messages to this group because you\'re no longer a member.</string>
|
||||
<string name="ConversationActivity_there_is_no_app_available_to_handle_this_link_on_your_device">There is no app available to handle this link on your device.</string>
|
||||
<string name="ConversationActivity_your_request_to_join_has_been_sent_to_the_group_admin">Your request to join has been sent to the group admin. You\'ll be notified when they take action.</string>
|
||||
<string name="ConversationActivity_cancel_request">Cancel Request</string>
|
||||
|
||||
<string name="ConversationActivity_to_send_audio_messages_allow_signal_access_to_your_microphone">To send audio messages, allow Signal access to your microphone.</string>
|
||||
<string name="ConversationActivity_signal_requires_the_microphone_permission_in_order_to_send_audio_messages">Signal requires the Microphone permission in order to send audio messages, but it has been permanently denied. Please continue to app settings, select \"Permissions\", and enable \"Microphone\".</string>
|
||||
@ -657,7 +659,10 @@
|
||||
<string name="GroupJoinBottomSheetDialogFragment_you_are_already_a_member">You are already a member</string>
|
||||
<string name="GroupJoinBottomSheetDialogFragment_join">Join</string>
|
||||
<string name="GroupJoinBottomSheetDialogFragment_request_to_join">Request to join</string>
|
||||
<string name="GroupJoinBottomSheetDialogFragment_unable_to_join_group_please_try_again_later">Unable to join group. Please try again later</string>
|
||||
<string name="GroupJoinBottomSheetDialogFragment_encountered_a_network_error">Encountered a network error.</string>
|
||||
<string name="GroupJoinBottomSheetDialogFragment_this_group_link_is_not_active">This group link is not active</string>
|
||||
|
||||
<string name="GroupJoinBottomSheetDialogFragment_unable_to_get_group_information_please_try_again_later">Unable to get group information, please try again later</string>
|
||||
<string name="GroupJoinBottomSheetDialogFragment_direct_join">Do you want to join this group and share your name and photo with its members?</string>
|
||||
<string name="GroupJoinBottomSheetDialogFragment_admin_approval_needed">An admin of this group must approve your request before you can join this group. When you request to join, your name and photo will be shared with its members.</string>
|
||||
@ -963,6 +968,7 @@
|
||||
<!-- GV2 group link approvals -->
|
||||
<string name="MessageRecord_s_approved_your_request_to_join_the_group">%1$s approved your request to join the group.</string>
|
||||
<string name="MessageRecord_s_approved_a_request_to_join_the_group_from_s">%1$s approved a request to join the group from %2$s.</string>
|
||||
<string name="MessageRecord_you_approved_a_request_to_join_the_group_from_s">You approved a request to join the group from %1$s.</string>
|
||||
<string name="MessageRecord_your_request_to_join_the_group_has_been_approved">Your request to join the group has been approved.</string>
|
||||
<string name="MessageRecord_a_request_to_join_the_group_from_s_has_been_approved">A request to join the group from %1$s has been approved.</string>
|
||||
|
||||
@ -970,6 +976,8 @@
|
||||
<string name="MessageRecord_your_request_to_join_the_group_has_been_denied_by_an_admin">Your request to join the group has been denied by an admin.</string>
|
||||
<string name="MessageRecord_s_denied_a_request_to_join_the_group_from_s">%1$s denied a request to join the group from %2$s.</string>
|
||||
<string name="MessageRecord_a_request_to_join_the_group_from_s_has_been_denied">A request to join the group from %1$s has been denied.</string>
|
||||
<string name="MessageRecord_you_canceled_your_request_to_join_the_group">You canceled your request to join the group.</string>
|
||||
<string name="MessageRecord_s_canceled_their_request_to_join_the_group">%1$s canceled their request to join the group.</string>
|
||||
|
||||
<!-- End of GV2 specific update messages -->
|
||||
|
||||
|
@ -1035,6 +1035,15 @@ public final class GroupsV2UpdateMessageProducerTest {
|
||||
assertThat(describeChange(change), is(singletonList("Alice approved a request to join the group from Bob.")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void you_approved_another_join_request() {
|
||||
DecryptedGroupChange change = changeBy(you)
|
||||
.approveRequest(alice)
|
||||
.build();
|
||||
|
||||
assertThat(describeChange(change), is(singletonList("You approved a request to join the group from Alice.")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unknown_approved_your_join_request() {
|
||||
DecryptedGroupChange change = changeByUnknown()
|
||||
@ -1071,6 +1080,24 @@ public final class GroupsV2UpdateMessageProducerTest {
|
||||
assertThat(describeChange(change), is(singletonList("Your request to join the group has been denied by an admin.")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void you_cancelled_your_join_request() {
|
||||
DecryptedGroupChange change = changeBy(you)
|
||||
.denyRequest(you)
|
||||
.build();
|
||||
|
||||
assertThat(describeChange(change), is(singletonList("You canceled your request to join the group.")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void member_cancelled_their_join_request() {
|
||||
DecryptedGroupChange change = changeBy(alice)
|
||||
.denyRequest(alice)
|
||||
.build();
|
||||
|
||||
assertThat(describeChange(change), is(singletonList("Alice canceled their request to join the group.")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unknown_denied_your_join_request() {
|
||||
DecryptedGroupChange change = changeByUnknown()
|
||||
|
@ -197,6 +197,10 @@ public final class GroupStateMapperTest {
|
||||
public void known_group_three_states_to_update_update_latest_handle_gap_with_changes() {
|
||||
DecryptedGroup currentState = state(0);
|
||||
ServerGroupLogEntry log1 = serverLogEntry(1);
|
||||
DecryptedGroup state3a = DecryptedGroup.newBuilder()
|
||||
.setRevision(3)
|
||||
.setTitle("Group Revision " + 3)
|
||||
.build();
|
||||
DecryptedGroup state3 = DecryptedGroup.newBuilder()
|
||||
.setRevision(3)
|
||||
.setTitle("Group Revision " + 3)
|
||||
@ -213,7 +217,7 @@ public final class GroupStateMapperTest {
|
||||
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, asList(log1, log3, log4)), LATEST);
|
||||
|
||||
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(asList(asLocal(log1),
|
||||
asLocal(log3),
|
||||
new LocalGroupLogEntry(state3a, log3.getChange()),
|
||||
new LocalGroupLogEntry(state3, DecryptedGroupChange.newBuilder()
|
||||
.setRevision(3)
|
||||
.setNewAvatar(DecryptedString.newBuilder().setValue("Lost Avatar Update"))
|
||||
@ -259,11 +263,16 @@ public final class GroupStateMapperTest {
|
||||
DecryptedMember newMember = DecryptedMember.newBuilder()
|
||||
.setUuid(UuidUtil.toByteString(UUID.randomUUID()))
|
||||
.build();
|
||||
ServerGroupLogEntry log8 = new ServerGroupLogEntry(DecryptedGroup.newBuilder()
|
||||
DecryptedGroup state7b = DecryptedGroup.newBuilder()
|
||||
.setRevision(8)
|
||||
.addMembers(newMember)
|
||||
.setTitle("Group Revision " + 8)
|
||||
.build(),
|
||||
.build();
|
||||
DecryptedGroup state8 = DecryptedGroup.newBuilder()
|
||||
.setRevision(8)
|
||||
.setTitle("Group Revision " + 8)
|
||||
.addMembers(newMember)
|
||||
.build();
|
||||
ServerGroupLogEntry log8 = new ServerGroupLogEntry(state8,
|
||||
change(8) );
|
||||
ServerGroupLogEntry log9 = new ServerGroupLogEntry(DecryptedGroup.newBuilder()
|
||||
.setRevision(9)
|
||||
@ -275,11 +284,11 @@ public final class GroupStateMapperTest {
|
||||
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, asList(log7, log8, log9)), LATEST);
|
||||
|
||||
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(asList(asLocal(log7),
|
||||
asLocal(log8),
|
||||
asLocal(new ServerGroupLogEntry(log8.getGroup(), DecryptedGroupChange.newBuilder()
|
||||
new LocalGroupLogEntry(state7b, log8.getChange()),
|
||||
new LocalGroupLogEntry(state8, DecryptedGroupChange.newBuilder()
|
||||
.setRevision(8)
|
||||
.addNewMembers(newMember)
|
||||
.build())),
|
||||
.build()),
|
||||
asLocal(log9))));
|
||||
assertNewState(new GlobalGroupState(log9.getGroup(), emptyList()), advanceGroupStateResult.getNewGlobalGroupState());
|
||||
assertEquals(log9.getGroup(), advanceGroupStateResult.getNewGlobalGroupState().getLocalState());
|
||||
@ -298,18 +307,136 @@ public final class GroupStateMapperTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void local_on_same_revision_but_incorrect_repair_necessary() {
|
||||
public void no_repair_change_is_posted_if_the_local_state_is_a_placeholder() {
|
||||
DecryptedGroup currentState = DecryptedGroup.newBuilder()
|
||||
.setRevision(6)
|
||||
.setRevision(GroupStateMapper.PLACEHOLDER_REVISION)
|
||||
.setTitle("Incorrect group title, Revision " + 6)
|
||||
.build();
|
||||
ServerGroupLogEntry log6 = serverLogEntryWholeStateOnly(6);
|
||||
ServerGroupLogEntry log6 = serverLogEntry(6);
|
||||
|
||||
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, singletonList(log6)), LATEST);
|
||||
|
||||
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(singletonList(localLogEntryNoEditor(6))));
|
||||
assertNewState(new GlobalGroupState(state(6), emptyList()), advanceGroupStateResult.getNewGlobalGroupState());
|
||||
assertEquals(state(6), advanceGroupStateResult.getNewGlobalGroupState().getLocalState());
|
||||
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(singletonList(asLocal(log6))));
|
||||
assertTrue(advanceGroupStateResult.getNewGlobalGroupState().getServerHistory().isEmpty());
|
||||
assertEquals(log6.getGroup(), advanceGroupStateResult.getNewGlobalGroupState().getLocalState());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void clears_changes_duplicated_in_the_placeholder() {
|
||||
UUID newMemberUuid = UUID.randomUUID();
|
||||
DecryptedMember newMember = DecryptedMember.newBuilder()
|
||||
.setUuid(UuidUtil.toByteString(newMemberUuid))
|
||||
.build();
|
||||
DecryptedMember existingMember = DecryptedMember.newBuilder()
|
||||
.setUuid(UuidUtil.toByteString(UUID.randomUUID()))
|
||||
.build();
|
||||
DecryptedGroup currentState = DecryptedGroup.newBuilder()
|
||||
.setRevision(GroupStateMapper.PLACEHOLDER_REVISION)
|
||||
.setTitle("Group Revision " + 8)
|
||||
.addMembers(newMember)
|
||||
.build();
|
||||
ServerGroupLogEntry log8 = new ServerGroupLogEntry(DecryptedGroup.newBuilder()
|
||||
.setRevision(8)
|
||||
.addMembers(newMember)
|
||||
.addMembers(existingMember)
|
||||
.setTitle("Group Revision " + 8)
|
||||
.build(),
|
||||
DecryptedGroupChange.newBuilder()
|
||||
.setRevision(8)
|
||||
.setEditor(UuidUtil.toByteString(newMemberUuid))
|
||||
.addNewMembers(newMember)
|
||||
.build());
|
||||
|
||||
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, singletonList(log8)), LATEST);
|
||||
|
||||
assertNotNull(log8.getGroup());
|
||||
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(emptyList()));
|
||||
assertNewState(new GlobalGroupState(log8.getGroup(), emptyList()), advanceGroupStateResult.getNewGlobalGroupState());
|
||||
assertEquals(log8.getGroup(), advanceGroupStateResult.getNewGlobalGroupState().getLocalState());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void clears_changes_duplicated_in_a_non_placeholder() {
|
||||
UUID editorUuid = UUID.randomUUID();
|
||||
UUID newMemberUuid = UUID.randomUUID();
|
||||
DecryptedMember newMember = DecryptedMember.newBuilder()
|
||||
.setUuid(UuidUtil.toByteString(newMemberUuid))
|
||||
.build();
|
||||
DecryptedMember existingMember = DecryptedMember.newBuilder()
|
||||
.setUuid(UuidUtil.toByteString(UUID.randomUUID()))
|
||||
.build();
|
||||
DecryptedGroup currentState = DecryptedGroup.newBuilder()
|
||||
.setRevision(8)
|
||||
.setTitle("Group Revision " + 8)
|
||||
.addMembers(existingMember)
|
||||
.build();
|
||||
ServerGroupLogEntry log8 = new ServerGroupLogEntry(DecryptedGroup.newBuilder()
|
||||
.setRevision(8)
|
||||
.addMembers(existingMember)
|
||||
.addMembers(newMember)
|
||||
.setTitle("Group Revision " + 8)
|
||||
.build(),
|
||||
DecryptedGroupChange.newBuilder()
|
||||
.setRevision(8)
|
||||
.setEditor(UuidUtil.toByteString(editorUuid))
|
||||
.addNewMembers(existingMember)
|
||||
.addNewMembers(newMember)
|
||||
.build());
|
||||
|
||||
DecryptedGroupChange expectedChange = DecryptedGroupChange.newBuilder()
|
||||
.setRevision(8)
|
||||
.setEditor(UuidUtil.toByteString(editorUuid))
|
||||
.addNewMembers(newMember)
|
||||
.build();
|
||||
|
||||
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, singletonList(log8)), LATEST);
|
||||
|
||||
assertNotNull(log8.getGroup());
|
||||
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(singletonList(new LocalGroupLogEntry(log8.getGroup(), expectedChange))));
|
||||
assertNewState(new GlobalGroupState(log8.getGroup(), emptyList()), advanceGroupStateResult.getNewGlobalGroupState());
|
||||
assertEquals(log8.getGroup(), advanceGroupStateResult.getNewGlobalGroupState().getLocalState());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void notices_changes_in_avatar_and_title_but_not_members_in_placeholder() {
|
||||
UUID newMemberUuid = UUID.randomUUID();
|
||||
DecryptedMember newMember = DecryptedMember.newBuilder()
|
||||
.setUuid(UuidUtil.toByteString(newMemberUuid))
|
||||
.build();
|
||||
DecryptedMember existingMember = DecryptedMember.newBuilder()
|
||||
.setUuid(UuidUtil.toByteString(UUID.randomUUID()))
|
||||
.build();
|
||||
DecryptedGroup currentState = DecryptedGroup.newBuilder()
|
||||
.setRevision(GroupStateMapper.PLACEHOLDER_REVISION)
|
||||
.setTitle("Incorrect group title")
|
||||
.setAvatar("Incorrect group avatar")
|
||||
.addMembers(newMember)
|
||||
.build();
|
||||
ServerGroupLogEntry log8 = new ServerGroupLogEntry(DecryptedGroup.newBuilder()
|
||||
.setRevision(8)
|
||||
.addMembers(newMember)
|
||||
.addMembers(existingMember)
|
||||
.setTitle("Group Revision " + 8)
|
||||
.setAvatar("Group Avatar " + 8)
|
||||
.build(),
|
||||
DecryptedGroupChange.newBuilder()
|
||||
.setRevision(8)
|
||||
.setEditor(UuidUtil.toByteString(newMemberUuid))
|
||||
.addNewMembers(newMember)
|
||||
.build());
|
||||
|
||||
DecryptedGroupChange expectedChange = DecryptedGroupChange.newBuilder()
|
||||
.setRevision(8)
|
||||
.setNewTitle(DecryptedString.newBuilder().setValue("Group Revision " + 8))
|
||||
.setNewAvatar(DecryptedString.newBuilder().setValue("Group Avatar " + 8))
|
||||
.build();
|
||||
|
||||
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, singletonList(log8)), LATEST);
|
||||
|
||||
assertNotNull(log8.getGroup());
|
||||
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(singletonList(new LocalGroupLogEntry(log8.getGroup(), expectedChange))));
|
||||
assertNewState(new GlobalGroupState(log8.getGroup(), emptyList()), advanceGroupStateResult.getNewGlobalGroupState());
|
||||
assertEquals(log8.getGroup(), advanceGroupStateResult.getNewGlobalGroupState().getLocalState());
|
||||
}
|
||||
|
||||
private static void assertNewState(GlobalGroupState expected, GlobalGroupState actual) {
|
||||
|
@ -0,0 +1,133 @@
|
||||
package org.thoughtcrime.securesms.groups.v2.processing;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static java.util.Arrays.asList;
|
||||
import static java.util.Collections.emptyList;
|
||||
import static java.util.Collections.singletonList;
|
||||
import static org.hamcrest.CoreMatchers.is;
|
||||
import static org.junit.Assert.assertThat;
|
||||
|
||||
public final class StateChainTest {
|
||||
|
||||
private static final int BAD_DELTA = 256;
|
||||
|
||||
private final StateChain<Character, Integer> stateChain = new StateChain<>(
|
||||
(c, d) -> {
|
||||
if (d == BAD_DELTA) return null;
|
||||
return (char) (c + d);
|
||||
},
|
||||
(a, b) -> a - b,
|
||||
(a, b)->a==b);
|
||||
|
||||
@Test
|
||||
public void push_one_state_pair() {
|
||||
stateChain.push('A', 0);
|
||||
|
||||
assertThat(stateChain.getList(), is(singletonList(pair('A', 0))));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void push_two_state_pairs() {
|
||||
stateChain.push('A', 0);
|
||||
stateChain.push('B', 1);
|
||||
|
||||
assertThat(stateChain.getList(), is(asList(pair('A', 0),
|
||||
pair('B', 1))));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void push_two_state_pairs_null_first_delta() {
|
||||
stateChain.push('A', null);
|
||||
stateChain.push('B', 1);
|
||||
|
||||
assertThat(stateChain.getList(), is(asList(pair('A', null),
|
||||
pair('B', 1))));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void push_two_state_pairs_with_missing_delta() {
|
||||
stateChain.push('A', 0);
|
||||
stateChain.push('B', null);
|
||||
|
||||
assertThat(stateChain.getList(), is(asList(pair('A', 0),
|
||||
pair('B', 1))));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void push_two_state_pairs_with_missing_state() {
|
||||
stateChain.push('A', 0);
|
||||
stateChain.push(null, 1);
|
||||
|
||||
assertThat(stateChain.getList(), is(asList(pair('A', 0),
|
||||
pair('B', 1))));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void push_one_state_pairs_with_missing_state_and_delta() {
|
||||
stateChain.push(null, null);
|
||||
|
||||
assertThat(stateChain.getList(), is(emptyList()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void push_two_state_pairs_with_missing_state_and_delta() {
|
||||
stateChain.push('A', 0);
|
||||
stateChain.push(null, null);
|
||||
|
||||
assertThat(stateChain.getList(), is(singletonList(pair('A', 0))));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void push_two_state_pairs_that_do_not_match() {
|
||||
stateChain.push('D', 0);
|
||||
stateChain.push('E', 2);
|
||||
|
||||
assertThat(stateChain.getList(), is(asList(pair('D', 0),
|
||||
pair('F', 2),
|
||||
pair('E', -1))));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void push_one_state_pair_null_delta() {
|
||||
stateChain.push('A', null);
|
||||
|
||||
assertThat(stateChain.getList(), is(singletonList(pair('A', null))));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void push_two_state_pairs_with_no_diff() {
|
||||
stateChain.push('Z', null);
|
||||
stateChain.push('Z', 0);
|
||||
|
||||
assertThat(stateChain.getList(), is(singletonList(pair('Z', null))));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void push_one_state_pair_null_state() {
|
||||
stateChain.push(null, 1);
|
||||
|
||||
assertThat(stateChain.getList(), is(emptyList()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bad_delta_results_in_reconstruction() {
|
||||
stateChain.push('C', 0);
|
||||
stateChain.push('F', BAD_DELTA);
|
||||
|
||||
assertThat(stateChain.getList(), is(asList(pair('C', 0),
|
||||
pair('F', 3))));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bad_delta_and_no_state_results_in_change_ignore() {
|
||||
stateChain.push('C', 0);
|
||||
stateChain.push(null, BAD_DELTA);
|
||||
|
||||
assertThat(stateChain.getList(), is(singletonList(pair('C', 0))));
|
||||
}
|
||||
|
||||
private static StateChain.Pair<Character, Integer> pair(char c, Integer i) {
|
||||
return new StateChain.Pair<>(c, i);
|
||||
}
|
||||
}
|
@ -0,0 +1,39 @@
|
||||
package org.whispersystems.signalservice.api.groupsv2;
|
||||
|
||||
public interface ChangeSetModifier {
|
||||
void removeAddMembers(int i);
|
||||
|
||||
void moveAddToPromote(int i);
|
||||
|
||||
void removeDeleteMembers(int i);
|
||||
|
||||
void removeModifyMemberRoles(int i);
|
||||
|
||||
void removeModifyMemberProfileKeys(int i);
|
||||
|
||||
void removeAddPendingMembers(int i);
|
||||
|
||||
void removeDeletePendingMembers(int i);
|
||||
|
||||
void removePromotePendingMembers(int i);
|
||||
|
||||
void clearModifyTitle();
|
||||
|
||||
void clearModifyAvatar();
|
||||
|
||||
void clearModifyDisappearingMessagesTimer();
|
||||
|
||||
void clearModifyAttributesAccess();
|
||||
|
||||
void clearModifyMemberAccess();
|
||||
|
||||
void clearModifyAddFromInviteLinkAccess();
|
||||
|
||||
void removeAddRequestingMembers(int i);
|
||||
|
||||
void moveAddRequestingMembersToPromote(int i);
|
||||
|
||||
void removeDeleteRequestingMembers(int i);
|
||||
|
||||
void removePromoteRequestingMembers(int i);
|
||||
}
|
@ -0,0 +1,133 @@
|
||||
package org.whispersystems.signalservice.api.groupsv2;
|
||||
|
||||
import com.google.protobuf.ByteString;
|
||||
|
||||
import org.signal.storageservice.protos.groups.Member;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedMember;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedRequestingMember;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
final class DecryptedGroupChangeActionsBuilderChangeSetModifier implements ChangeSetModifier {
|
||||
private final DecryptedGroupChange.Builder result;
|
||||
|
||||
public DecryptedGroupChangeActionsBuilderChangeSetModifier(DecryptedGroupChange.Builder result) {
|
||||
this.result = result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeAddMembers(int i) {
|
||||
result.removeNewMembers(i);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void moveAddToPromote(int i) {
|
||||
DecryptedMember addMemberAction = result.getNewMembersList().get(i);
|
||||
result.removeNewMembers(i);
|
||||
result.addPromotePendingMembers(addMemberAction);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeDeleteMembers(int i) {
|
||||
List<ByteString> newList = removeIndexFromByteStringList(result.getDeleteMembersList(), i);
|
||||
|
||||
result.clearDeleteMembers()
|
||||
.addAllDeleteMembers(newList);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeModifyMemberRoles(int i) {
|
||||
result.removeModifyMemberRoles(i);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeModifyMemberProfileKeys(int i) {
|
||||
result.removeModifiedProfileKeys(i);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeAddPendingMembers(int i) {
|
||||
result.removeNewPendingMembers(i);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeDeletePendingMembers(int i) {
|
||||
result.removeDeletePendingMembers(i);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removePromotePendingMembers(int i) {
|
||||
result.removePromotePendingMembers(i);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearModifyTitle() {
|
||||
result.clearNewTitle();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearModifyAvatar() {
|
||||
result.clearNewAvatar();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearModifyDisappearingMessagesTimer() {
|
||||
result.clearNewTimer();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearModifyAttributesAccess() {
|
||||
result.clearNewAttributeAccess();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearModifyMemberAccess() {
|
||||
result.clearNewMemberAccess();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearModifyAddFromInviteLinkAccess() {
|
||||
result.clearNewInviteLinkAccess();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeAddRequestingMembers(int i) {
|
||||
result.removeNewRequestingMembers(i);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void moveAddRequestingMembersToPromote(int i) {
|
||||
DecryptedRequestingMember addMemberAction = result.getNewRequestingMembersList().get(i);
|
||||
result.removeNewRequestingMembers(i);
|
||||
|
||||
DecryptedMember build = DecryptedMember.newBuilder()
|
||||
.setUuid(addMemberAction.getUuid())
|
||||
.setProfileKey(addMemberAction.getProfileKey())
|
||||
.setRole(Member.Role.DEFAULT).build();
|
||||
|
||||
result.addPromotePendingMembers(0, build);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeDeleteRequestingMembers(int i) {
|
||||
List<ByteString> newList = removeIndexFromByteStringList(result.getDeleteRequestingMembersList(), i);
|
||||
|
||||
result.clearDeleteRequestingMembers()
|
||||
.addAllDeleteRequestingMembers(newList);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removePromoteRequestingMembers(int i) {
|
||||
result.removePromoteRequestingMembers(i);
|
||||
}
|
||||
|
||||
private static List<ByteString> removeIndexFromByteStringList(List<ByteString> byteStrings, int i) {
|
||||
List<ByteString> modifiedList = new ArrayList<>(byteStrings);
|
||||
|
||||
modifiedList.remove(i);
|
||||
|
||||
return modifiedList;
|
||||
}
|
||||
}
|
@ -186,6 +186,23 @@ public final class DecryptedGroupUtil {
|
||||
return -1;
|
||||
}
|
||||
|
||||
public static Optional<DecryptedRequestingMember> findRequestingByUuid(Collection<DecryptedRequestingMember> members, UUID uuid) {
|
||||
ByteString uuidBytes = UuidUtil.toByteString(uuid);
|
||||
|
||||
for (DecryptedRequestingMember member : members) {
|
||||
if (uuidBytes.equals(member.getUuid())) {
|
||||
return Optional.of(member);
|
||||
}
|
||||
}
|
||||
|
||||
return Optional.absent();
|
||||
}
|
||||
|
||||
public static boolean isPendingOrRequesting(DecryptedGroup group, UUID uuid) {
|
||||
return findPendingByUuid(group.getPendingMembersList(), uuid).isPresent() ||
|
||||
findRequestingByUuid(group.getRequestingMembersList(), uuid).isPresent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the uuid from the full members of a group.
|
||||
* <p>
|
||||
|
@ -0,0 +1,105 @@
|
||||
package org.whispersystems.signalservice.api.groupsv2;
|
||||
|
||||
import org.signal.storageservice.protos.groups.GroupChange;
|
||||
|
||||
final class GroupChangeActionsBuilderChangeSetModifier implements ChangeSetModifier {
|
||||
private final GroupChange.Actions.Builder result;
|
||||
|
||||
public GroupChangeActionsBuilderChangeSetModifier(GroupChange.Actions.Builder result) {
|
||||
this.result = result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeAddMembers(int i) {
|
||||
result.removeAddMembers(i);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void moveAddToPromote(int i) {
|
||||
GroupChange.Actions.AddMemberAction addMemberAction = result.getAddMembersList().get(i);
|
||||
result.removeAddMembers(i);
|
||||
result.addPromotePendingMembers(GroupChange.Actions.PromotePendingMemberAction.newBuilder().setPresentation(addMemberAction.getAdded().getPresentation()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeDeleteMembers(int i) {
|
||||
result.removeDeleteMembers(i);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeModifyMemberRoles(int i) {
|
||||
result.removeModifyMemberRoles(i);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeModifyMemberProfileKeys(int i) {
|
||||
result.removeModifyMemberProfileKeys(i);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeAddPendingMembers(int i) {
|
||||
result.removeAddPendingMembers(i);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeDeletePendingMembers(int i) {
|
||||
result.removeDeletePendingMembers(i);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removePromotePendingMembers(int i) {
|
||||
result.removePromotePendingMembers(i);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearModifyTitle() {
|
||||
result.clearModifyTitle();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearModifyAvatar() {
|
||||
result.clearModifyAvatar();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearModifyDisappearingMessagesTimer() {
|
||||
result.clearModifyDisappearingMessagesTimer();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearModifyAttributesAccess() {
|
||||
result.clearModifyAttributesAccess();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearModifyMemberAccess() {
|
||||
result.clearModifyMemberAccess();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearModifyAddFromInviteLinkAccess() {
|
||||
result.clearModifyAddFromInviteLinkAccess();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeAddRequestingMembers(int i) {
|
||||
result.removeAddRequestingMembers(i);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void moveAddRequestingMembersToPromote(int i) {
|
||||
GroupChange.Actions.AddRequestingMemberAction addMemberAction = result.getAddRequestingMembersList().get(i);
|
||||
result.removeAddRequestingMembers(i);
|
||||
result.addPromotePendingMembers(0, GroupChange.Actions.PromotePendingMemberAction.newBuilder().setPresentation(addMemberAction.getAdded().getPresentation()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeDeleteRequestingMembers(int i) {
|
||||
result.removeDeleteRequestingMembers(i);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removePromoteRequestingMembers(int i) {
|
||||
result.removePromoteRequestingMembers(i);
|
||||
}
|
||||
}
|
@ -65,6 +65,42 @@ public final class GroupChangeUtil {
|
||||
GroupChange.Actions encryptedChange)
|
||||
{
|
||||
GroupChange.Actions.Builder result = GroupChange.Actions.newBuilder(encryptedChange);
|
||||
|
||||
resolveConflict(groupState, conflictingChange, new GroupChangeActionsBuilderChangeSetModifier(result));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the latest group state and a conflicting change, decides which changes to carry forward
|
||||
* and returns a new group change which could be empty.
|
||||
* <p>
|
||||
* Titles, avatars, and other settings are carried forward if they are different. Last writer wins.
|
||||
* <p>
|
||||
* Membership additions and removals also respect last writer wins and are removed if they have
|
||||
* already been applied. e.g. you add someone but they are already added.
|
||||
* <p>
|
||||
* Membership additions will be altered to {@link DecryptedGroupChange} promotes if someone has
|
||||
* invited them since.
|
||||
*
|
||||
* @param groupState Latest group state in plaintext.
|
||||
* @param conflictingChange The potentially conflicting change in plaintext.
|
||||
* @return A new change builder.
|
||||
*/
|
||||
public static DecryptedGroupChange.Builder resolveConflict(DecryptedGroup groupState,
|
||||
DecryptedGroupChange conflictingChange)
|
||||
{
|
||||
DecryptedGroupChange.Builder result = DecryptedGroupChange.newBuilder(conflictingChange);
|
||||
|
||||
resolveConflict(groupState, conflictingChange, new DecryptedGroupChangeActionsBuilderChangeSetModifier(result));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void resolveConflict(DecryptedGroup groupState,
|
||||
DecryptedGroupChange conflictingChange,
|
||||
ChangeSetModifier changeSetModifier)
|
||||
{
|
||||
HashMap<ByteString, DecryptedMember> fullMembersByUuid = new HashMap<>(groupState.getMembersCount());
|
||||
HashMap<ByteString, DecryptedPendingMember> pendingMembersByUuid = new HashMap<>(groupState.getPendingMembersCount());
|
||||
HashMap<ByteString, DecryptedRequestingMember> requestingMembersByUuid = new HashMap<>(groupState.getMembersCount());
|
||||
@ -81,27 +117,25 @@ public final class GroupChangeUtil {
|
||||
requestingMembersByUuid.put(member.getUuid(), member);
|
||||
}
|
||||
|
||||
resolveField3AddMembers (conflictingChange, result, fullMembersByUuid, pendingMembersByUuid);
|
||||
resolveField4DeleteMembers (conflictingChange, result, fullMembersByUuid);
|
||||
resolveField5ModifyMemberRoles (conflictingChange, result, fullMembersByUuid);
|
||||
resolveField6ModifyProfileKeys (conflictingChange, result, fullMembersByUuid);
|
||||
resolveField7AddPendingMembers (conflictingChange, result, fullMembersByUuid, pendingMembersByUuid);
|
||||
resolveField8DeletePendingMembers (conflictingChange, result, pendingMembersByUuid);
|
||||
resolveField9PromotePendingMembers (conflictingChange, result, pendingMembersByUuid);
|
||||
resolveField10ModifyTitle (groupState, conflictingChange, result);
|
||||
resolveField11ModifyAvatar (groupState, conflictingChange, result);
|
||||
resolveField12modifyDisappearingMessagesTimer(groupState, conflictingChange, result);
|
||||
resolveField13modifyAttributesAccess (groupState, conflictingChange, result);
|
||||
resolveField14modifyAttributesAccess (groupState, conflictingChange, result);
|
||||
resolveField15modifyAddFromInviteLinkAccess (groupState, conflictingChange, result);
|
||||
resolveField16AddRequestingMembers (conflictingChange, result, fullMembersByUuid, pendingMembersByUuid);
|
||||
resolveField17DeleteMembers (conflictingChange, result, requestingMembersByUuid);
|
||||
resolveField18PromoteRequestingMembers (conflictingChange, result, requestingMembersByUuid);
|
||||
|
||||
return result;
|
||||
resolveField3AddMembers (conflictingChange, changeSetModifier, fullMembersByUuid, pendingMembersByUuid);
|
||||
resolveField4DeleteMembers (conflictingChange, changeSetModifier, fullMembersByUuid);
|
||||
resolveField5ModifyMemberRoles (conflictingChange, changeSetModifier, fullMembersByUuid);
|
||||
resolveField6ModifyProfileKeys (conflictingChange, changeSetModifier, fullMembersByUuid);
|
||||
resolveField7AddPendingMembers (conflictingChange, changeSetModifier, fullMembersByUuid, pendingMembersByUuid);
|
||||
resolveField8DeletePendingMembers (conflictingChange, changeSetModifier, pendingMembersByUuid);
|
||||
resolveField9PromotePendingMembers (conflictingChange, changeSetModifier, pendingMembersByUuid);
|
||||
resolveField10ModifyTitle (groupState, conflictingChange, changeSetModifier);
|
||||
resolveField11ModifyAvatar (groupState, conflictingChange, changeSetModifier);
|
||||
resolveField12modifyDisappearingMessagesTimer(groupState, conflictingChange, changeSetModifier);
|
||||
resolveField13modifyAttributesAccess (groupState, conflictingChange, changeSetModifier);
|
||||
resolveField14modifyAttributesAccess (groupState, conflictingChange, changeSetModifier);
|
||||
resolveField15modifyAddFromInviteLinkAccess (groupState, conflictingChange, changeSetModifier);
|
||||
resolveField16AddRequestingMembers (conflictingChange, changeSetModifier, fullMembersByUuid, pendingMembersByUuid);
|
||||
resolveField17DeleteMembers (conflictingChange, changeSetModifier, requestingMembersByUuid);
|
||||
resolveField18PromoteRequestingMembers (conflictingChange, changeSetModifier, requestingMembersByUuid);
|
||||
}
|
||||
|
||||
private static void resolveField3AddMembers(DecryptedGroupChange conflictingChange, GroupChange.Actions.Builder result, HashMap<ByteString, DecryptedMember> fullMembersByUuid, HashMap<ByteString, DecryptedPendingMember> pendingMembersByUuid) {
|
||||
private static void resolveField3AddMembers(DecryptedGroupChange conflictingChange, ChangeSetModifier result, HashMap<ByteString, DecryptedMember> fullMembersByUuid, HashMap<ByteString, DecryptedPendingMember> pendingMembersByUuid) {
|
||||
List<DecryptedMember> newMembersList = conflictingChange.getNewMembersList();
|
||||
|
||||
for (int i = newMembersList.size() - 1; i >= 0; i--) {
|
||||
@ -110,14 +144,12 @@ public final class GroupChangeUtil {
|
||||
if (fullMembersByUuid.containsKey(member.getUuid())) {
|
||||
result.removeAddMembers(i);
|
||||
} else if (pendingMembersByUuid.containsKey(member.getUuid())) {
|
||||
GroupChange.Actions.AddMemberAction addMemberAction = result.getAddMembersList().get(i);
|
||||
result.removeAddMembers(i);
|
||||
result.addPromotePendingMembers(GroupChange.Actions.PromotePendingMemberAction.newBuilder().setPresentation(addMemberAction.getAdded().getPresentation()));
|
||||
result.moveAddToPromote(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void resolveField4DeleteMembers(DecryptedGroupChange conflictingChange, GroupChange.Actions.Builder result, HashMap<ByteString, DecryptedMember> fullMembersByUuid) {
|
||||
private static void resolveField4DeleteMembers(DecryptedGroupChange conflictingChange, ChangeSetModifier result, HashMap<ByteString, DecryptedMember> fullMembersByUuid) {
|
||||
List<ByteString> deletedMembersList = conflictingChange.getDeleteMembersList();
|
||||
|
||||
for (int i = deletedMembersList.size() - 1; i >= 0; i--) {
|
||||
@ -129,7 +161,7 @@ public final class GroupChangeUtil {
|
||||
}
|
||||
}
|
||||
|
||||
private static void resolveField5ModifyMemberRoles(DecryptedGroupChange conflictingChange, GroupChange.Actions.Builder result, HashMap<ByteString, DecryptedMember> fullMembersByUuid) {
|
||||
private static void resolveField5ModifyMemberRoles(DecryptedGroupChange conflictingChange, ChangeSetModifier result, HashMap<ByteString, DecryptedMember> fullMembersByUuid) {
|
||||
List<DecryptedModifyMemberRole> modifyRolesList = conflictingChange.getModifyMemberRolesList();
|
||||
|
||||
for (int i = modifyRolesList.size() - 1; i >= 0; i--) {
|
||||
@ -142,7 +174,7 @@ public final class GroupChangeUtil {
|
||||
}
|
||||
}
|
||||
|
||||
private static void resolveField6ModifyProfileKeys(DecryptedGroupChange conflictingChange, GroupChange.Actions.Builder result, HashMap<ByteString, DecryptedMember> fullMembersByUuid) {
|
||||
private static void resolveField6ModifyProfileKeys(DecryptedGroupChange conflictingChange, ChangeSetModifier result, HashMap<ByteString, DecryptedMember> fullMembersByUuid) {
|
||||
List<DecryptedMember> modifyProfileKeysList = conflictingChange.getModifiedProfileKeysList();
|
||||
|
||||
for (int i = modifyProfileKeysList.size() - 1; i >= 0; i--) {
|
||||
@ -155,7 +187,7 @@ public final class GroupChangeUtil {
|
||||
}
|
||||
}
|
||||
|
||||
private static void resolveField7AddPendingMembers(DecryptedGroupChange conflictingChange, GroupChange.Actions.Builder result, HashMap<ByteString, DecryptedMember> fullMembersByUuid, HashMap<ByteString, DecryptedPendingMember> pendingMembersByUuid) {
|
||||
private static void resolveField7AddPendingMembers(DecryptedGroupChange conflictingChange, ChangeSetModifier result, HashMap<ByteString, DecryptedMember> fullMembersByUuid, HashMap<ByteString, DecryptedPendingMember> pendingMembersByUuid) {
|
||||
List<DecryptedPendingMember> newPendingMembersList = conflictingChange.getNewPendingMembersList();
|
||||
|
||||
for (int i = newPendingMembersList.size() - 1; i >= 0; i--) {
|
||||
@ -167,7 +199,7 @@ public final class GroupChangeUtil {
|
||||
}
|
||||
}
|
||||
|
||||
private static void resolveField8DeletePendingMembers(DecryptedGroupChange conflictingChange, GroupChange.Actions.Builder result, HashMap<ByteString, DecryptedPendingMember> pendingMembersByUuid) {
|
||||
private static void resolveField8DeletePendingMembers(DecryptedGroupChange conflictingChange, ChangeSetModifier result, HashMap<ByteString, DecryptedPendingMember> pendingMembersByUuid) {
|
||||
List<DecryptedPendingMemberRemoval> deletePendingMembersList = conflictingChange.getDeletePendingMembersList();
|
||||
|
||||
for (int i = deletePendingMembersList.size() - 1; i >= 0; i--) {
|
||||
@ -179,7 +211,7 @@ public final class GroupChangeUtil {
|
||||
}
|
||||
}
|
||||
|
||||
private static void resolveField9PromotePendingMembers(DecryptedGroupChange conflictingChange, GroupChange.Actions.Builder result, HashMap<ByteString, DecryptedPendingMember> pendingMembersByUuid) {
|
||||
private static void resolveField9PromotePendingMembers(DecryptedGroupChange conflictingChange, ChangeSetModifier result, HashMap<ByteString, DecryptedPendingMember> pendingMembersByUuid) {
|
||||
List<DecryptedMember> promotePendingMembersList = conflictingChange.getPromotePendingMembersList();
|
||||
|
||||
for (int i = promotePendingMembersList.size() - 1; i >= 0; i--) {
|
||||
@ -191,43 +223,43 @@ public final class GroupChangeUtil {
|
||||
}
|
||||
}
|
||||
|
||||
private static void resolveField10ModifyTitle(DecryptedGroup groupState, DecryptedGroupChange conflictingChange, GroupChange.Actions.Builder result) {
|
||||
private static void resolveField10ModifyTitle(DecryptedGroup groupState, DecryptedGroupChange conflictingChange, ChangeSetModifier result) {
|
||||
if (conflictingChange.hasNewTitle() && conflictingChange.getNewTitle().getValue().equals(groupState.getTitle())) {
|
||||
result.clearModifyTitle();
|
||||
}
|
||||
}
|
||||
|
||||
private static void resolveField11ModifyAvatar(DecryptedGroup groupState, DecryptedGroupChange conflictingChange, GroupChange.Actions.Builder result) {
|
||||
private static void resolveField11ModifyAvatar(DecryptedGroup groupState, DecryptedGroupChange conflictingChange, ChangeSetModifier result) {
|
||||
if (conflictingChange.hasNewAvatar() && conflictingChange.getNewAvatar().getValue().equals(groupState.getAvatar())) {
|
||||
result.clearModifyAvatar();
|
||||
}
|
||||
}
|
||||
|
||||
private static void resolveField12modifyDisappearingMessagesTimer(DecryptedGroup groupState, DecryptedGroupChange conflictingChange, GroupChange.Actions.Builder result) {
|
||||
private static void resolveField12modifyDisappearingMessagesTimer(DecryptedGroup groupState, DecryptedGroupChange conflictingChange, ChangeSetModifier result) {
|
||||
if (conflictingChange.hasNewTimer() && conflictingChange.getNewTimer().getDuration() == groupState.getDisappearingMessagesTimer().getDuration()) {
|
||||
result.clearModifyDisappearingMessagesTimer();
|
||||
}
|
||||
}
|
||||
|
||||
private static void resolveField13modifyAttributesAccess(DecryptedGroup groupState, DecryptedGroupChange conflictingChange, GroupChange.Actions.Builder result) {
|
||||
private static void resolveField13modifyAttributesAccess(DecryptedGroup groupState, DecryptedGroupChange conflictingChange, ChangeSetModifier result) {
|
||||
if (conflictingChange.getNewAttributeAccess() == groupState.getAccessControl().getAttributes()) {
|
||||
result.clearModifyAttributesAccess();
|
||||
}
|
||||
}
|
||||
|
||||
private static void resolveField14modifyAttributesAccess(DecryptedGroup groupState, DecryptedGroupChange conflictingChange, GroupChange.Actions.Builder result) {
|
||||
private static void resolveField14modifyAttributesAccess(DecryptedGroup groupState, DecryptedGroupChange conflictingChange, ChangeSetModifier result) {
|
||||
if (conflictingChange.getNewMemberAccess() == groupState.getAccessControl().getMembers()) {
|
||||
result.clearModifyMemberAccess();
|
||||
}
|
||||
}
|
||||
|
||||
private static void resolveField15modifyAddFromInviteLinkAccess(DecryptedGroup groupState, DecryptedGroupChange conflictingChange, GroupChange.Actions.Builder result) {
|
||||
private static void resolveField15modifyAddFromInviteLinkAccess(DecryptedGroup groupState, DecryptedGroupChange conflictingChange, ChangeSetModifier result) {
|
||||
if (conflictingChange.getNewInviteLinkAccess() == groupState.getAccessControl().getAddFromInviteLink()) {
|
||||
result.clearModifyAddFromInviteLinkAccess();
|
||||
}
|
||||
}
|
||||
|
||||
private static void resolveField16AddRequestingMembers(DecryptedGroupChange conflictingChange, GroupChange.Actions.Builder result, HashMap<ByteString, DecryptedMember> fullMembersByUuid, HashMap<ByteString, DecryptedPendingMember> pendingMembersByUuid) {
|
||||
private static void resolveField16AddRequestingMembers(DecryptedGroupChange conflictingChange, ChangeSetModifier result, HashMap<ByteString, DecryptedMember> fullMembersByUuid, HashMap<ByteString, DecryptedPendingMember> pendingMembersByUuid) {
|
||||
List<DecryptedRequestingMember> newMembersList = conflictingChange.getNewRequestingMembersList();
|
||||
|
||||
for (int i = newMembersList.size() - 1; i >= 0; i--) {
|
||||
@ -236,15 +268,13 @@ public final class GroupChangeUtil {
|
||||
if (fullMembersByUuid.containsKey(member.getUuid())) {
|
||||
result.removeAddRequestingMembers(i);
|
||||
} else if (pendingMembersByUuid.containsKey(member.getUuid())) {
|
||||
GroupChange.Actions.AddRequestingMemberAction addMemberAction = result.getAddRequestingMembersList().get(i);
|
||||
result.removeAddRequestingMembers(i);
|
||||
result.addPromotePendingMembers(0, GroupChange.Actions.PromotePendingMemberAction.newBuilder().setPresentation(addMemberAction.getAdded().getPresentation()));
|
||||
result.moveAddRequestingMembersToPromote(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void resolveField17DeleteMembers(DecryptedGroupChange conflictingChange,
|
||||
GroupChange.Actions.Builder result,
|
||||
ChangeSetModifier result,
|
||||
HashMap<ByteString, DecryptedRequestingMember> requestingMembers)
|
||||
{
|
||||
List<ByteString> deletedMembersList = conflictingChange.getDeleteRequestingMembersList();
|
||||
@ -259,7 +289,7 @@ public final class GroupChangeUtil {
|
||||
}
|
||||
|
||||
private static void resolveField18PromoteRequestingMembers(DecryptedGroupChange conflictingChange,
|
||||
GroupChange.Actions.Builder result,
|
||||
ChangeSetModifier result,
|
||||
HashMap<ByteString, DecryptedRequestingMember> requestingMembersByUuid)
|
||||
{
|
||||
List<DecryptedApproveMember> promoteRequestingMembersList = conflictingChange.getPromoteRequestingMembersList();
|
||||
|
@ -7,6 +7,10 @@ package org.whispersystems.signalservice.api.groupsv2;
|
||||
* - the master key does not match a group on the server
|
||||
*/
|
||||
public final class GroupLinkNotActiveException extends Exception {
|
||||
GroupLinkNotActiveException() {
|
||||
public GroupLinkNotActiveException() {
|
||||
}
|
||||
|
||||
public GroupLinkNotActiveException(Throwable t) {
|
||||
super(t);
|
||||
}
|
||||
}
|
||||
|
@ -114,7 +114,7 @@ public final class GroupsV2Api {
|
||||
}
|
||||
|
||||
public DecryptedGroupJoinInfo getGroupJoinInfo(GroupSecretParams groupSecretParams,
|
||||
byte[] password,
|
||||
Optional<byte[]> password,
|
||||
GroupsV2AuthorizationString authorization)
|
||||
throws IOException, GroupLinkNotActiveException
|
||||
{
|
||||
@ -148,10 +148,11 @@ public final class GroupsV2Api {
|
||||
}
|
||||
|
||||
public GroupChange patchGroup(GroupChange.Actions groupChange,
|
||||
GroupsV2AuthorizationString authorization)
|
||||
GroupsV2AuthorizationString authorization,
|
||||
Optional<byte[]> groupLinkPassword)
|
||||
throws IOException
|
||||
{
|
||||
return socket.patchGroupsV2Group(groupChange, authorization.toString());
|
||||
return socket.patchGroupsV2Group(groupChange, authorization.toString(), groupLinkPassword);
|
||||
}
|
||||
|
||||
private static HashMap<Integer, AuthCredentialResponse> parseCredentialResponse(CredentialResponse credentialResponse)
|
||||
|
@ -188,8 +188,7 @@ public final class GroupsV2Operations {
|
||||
|
||||
actions.addAddMembers(GroupChange.Actions.AddMemberAction
|
||||
.newBuilder()
|
||||
.setAdded(groupOperations.member(profileKeyCredential, Member.Role.DEFAULT))
|
||||
.setJoinFromInviteLink(true));
|
||||
.setAdded(groupOperations.member(profileKeyCredential, Member.Role.DEFAULT)));
|
||||
|
||||
return actions;
|
||||
}
|
||||
@ -574,6 +573,7 @@ public final class GroupsV2Operations {
|
||||
.setMemberCount(joinInfo.getMemberCount())
|
||||
.setAddFromInviteLink(joinInfo.getAddFromInviteLink())
|
||||
.setRevision(joinInfo.getRevision())
|
||||
.setPendingAdminApproval(joinInfo.getPendingAdminApproval())
|
||||
.build();
|
||||
}
|
||||
|
||||
|
@ -1957,11 +1957,14 @@ public class PushServiceSocket {
|
||||
return AvatarUploadAttributes.parseFrom(readBodyBytes(response));
|
||||
}
|
||||
|
||||
public GroupChange patchGroupsV2Group(GroupChange.Actions groupChange, String authorization)
|
||||
public GroupChange patchGroupsV2Group(GroupChange.Actions groupChange, String authorization, Optional<byte[]> groupLinkPassword)
|
||||
throws NonSuccessfulResponseCodeException, PushNetworkException, InvalidProtocolBufferException
|
||||
{
|
||||
String path = groupLinkPassword.transform(p -> String.format(GROUPSV2_GROUP_PASSWORD, Base64UrlSafe.encodeBytesWithoutPadding(p)))
|
||||
.or(GROUPSV2_GROUP);
|
||||
|
||||
ResponseBody response = makeStorageRequest(authorization,
|
||||
GROUPSV2_GROUP,
|
||||
path,
|
||||
"PATCH",
|
||||
protobufRequestBody(groupChange),
|
||||
GROUPS_V2_PATCH_RESPONSE_HANDLER);
|
||||
@ -1981,11 +1984,12 @@ public class PushServiceSocket {
|
||||
return GroupChanges.parseFrom(readBodyBytes(response));
|
||||
}
|
||||
|
||||
public GroupJoinInfo getGroupJoinInfo(byte[] groupLinkPassword, GroupsV2AuthorizationString authorization)
|
||||
public GroupJoinInfo getGroupJoinInfo(Optional<byte[]> groupLinkPassword, GroupsV2AuthorizationString authorization)
|
||||
throws NonSuccessfulResponseCodeException, PushNetworkException, InvalidProtocolBufferException
|
||||
{
|
||||
String passwordParam = groupLinkPassword.transform(Base64UrlSafe::encodeBytesWithoutPadding).or("");
|
||||
ResponseBody response = makeStorageRequest(authorization.toString(),
|
||||
String.format(GROUPSV2_GROUP_JOIN, Base64UrlSafe.encodeBytesWithoutPadding(groupLinkPassword)),
|
||||
String.format(GROUPSV2_GROUP_JOIN, passwordParam),
|
||||
"GET",
|
||||
null,
|
||||
GROUPS_V2_GET_JOIN_INFO_HANDLER);
|
||||
|
@ -100,4 +100,5 @@ message DecryptedGroupJoinInfo {
|
||||
uint32 memberCount = 4;
|
||||
AccessControl.AccessRequired addFromInviteLink = 5;
|
||||
uint32 revision = 6;
|
||||
bool pendingAdminApproval = 7;
|
||||
}
|
||||
|
@ -208,4 +208,5 @@ message GroupJoinInfo {
|
||||
uint32 memberCount = 4;
|
||||
AccessControl.AccessRequired addFromInviteLink = 5;
|
||||
uint32 revision = 6;
|
||||
bool pendingAdminApproval = 7;
|
||||
}
|
||||
|
@ -39,7 +39,7 @@ public final class GroupChangeUtil_resolveConflict_Test {
|
||||
/**
|
||||
* Reflects over the generated protobuf class and ensures that no new fields have been added since we wrote this.
|
||||
* <p>
|
||||
* If we didn't, newly added fields would not be resolved by {@link GroupChangeUtil#resolveConflict}.
|
||||
* If we didn't, newly added fields would not be resolved by {@link GroupChangeUtil#resolveConflict(DecryptedGroup, DecryptedGroupChange, GroupChange.Actions)}.
|
||||
*/
|
||||
@Test
|
||||
public void ensure_resolveConflict_knows_about_all_fields_of_DecryptedGroupChange() {
|
||||
@ -52,7 +52,7 @@ public final class GroupChangeUtil_resolveConflict_Test {
|
||||
/**
|
||||
* Reflects over the generated protobuf class and ensures that no new fields have been added since we wrote this.
|
||||
* <p>
|
||||
* If we didn't, newly added fields would not be resolved by {@link GroupChangeUtil#resolveConflict}.
|
||||
* If we didn't, newly added fields would not be resolved by {@link GroupChangeUtil#resolveConflict(DecryptedGroup, DecryptedGroupChange, GroupChange.Actions)}.
|
||||
*/
|
||||
@Test
|
||||
public void ensure_resolveConflict_knows_about_all_fields_of_GroupChange() {
|
||||
@ -65,7 +65,7 @@ public final class GroupChangeUtil_resolveConflict_Test {
|
||||
/**
|
||||
* Reflects over the generated protobuf class and ensures that no new fields have been added since we wrote this.
|
||||
* <p>
|
||||
* If we didn't, newly added fields would not be resolved by {@link GroupChangeUtil#resolveConflict}.
|
||||
* If we didn't, newly added fields would not be resolved by {@link GroupChangeUtil#resolveConflict(DecryptedGroup, DecryptedGroupChange, GroupChange.Actions)}.
|
||||
*/
|
||||
@Test
|
||||
public void ensure_resolveConflict_knows_about_all_fields_of_DecryptedGroup() {
|
||||
|
@ -0,0 +1,546 @@
|
||||
package org.whispersystems.signalservice.api.groupsv2;
|
||||
|
||||
import com.google.protobuf.ByteString;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.signal.storageservice.protos.groups.AccessControl;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedString;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedTimer;
|
||||
import org.signal.zkgroup.profiles.ProfileKey;
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil;
|
||||
import org.whispersystems.signalservice.internal.util.Util;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.admin;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.approveMember;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.demoteAdmin;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.member;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.pendingMember;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.pendingMemberRemoval;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.promoteAdmin;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.randomProfileKey;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.requestingMember;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtobufTestUtils.getMaxDeclaredFieldNumber;
|
||||
|
||||
public final class GroupChangeUtil_resolveConflict_decryptedOnly_Test {
|
||||
|
||||
/**
|
||||
* Reflects over the generated protobuf class and ensures that no new fields have been added since we wrote this.
|
||||
* <p>
|
||||
* If we didn't, newly added fields would not be resolved by {@link GroupChangeUtil#resolveConflict(DecryptedGroup, DecryptedGroupChange)}.
|
||||
*/
|
||||
@Test
|
||||
public void ensure_resolveConflict_knows_about_all_fields_of_DecryptedGroupChange() {
|
||||
int maxFieldFound = getMaxDeclaredFieldNumber(DecryptedGroupChange.class);
|
||||
|
||||
assertEquals("GroupChangeUtil#resolveConflict and its tests need updating to account for new fields on " + DecryptedGroupChange.class.getName(),
|
||||
19, maxFieldFound);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reflects over the generated protobuf class and ensures that no new fields have been added since we wrote this.
|
||||
* <p>
|
||||
* If we didn't, newly added fields would not be resolved by {@link GroupChangeUtil#resolveConflict(DecryptedGroup, DecryptedGroupChange)}.
|
||||
*/
|
||||
@Test
|
||||
public void ensure_resolveConflict_knows_about_all_fields_of_DecryptedGroup() {
|
||||
int maxFieldFound = getMaxDeclaredFieldNumber(DecryptedGroup.class);
|
||||
|
||||
assertEquals("GroupChangeUtil#resolveConflict and its tests need updating to account for new fields on " + DecryptedGroup.class.getName(),
|
||||
10, maxFieldFound);
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
public void field_3__changes_to_add_existing_members_are_excluded() {
|
||||
UUID member1 = UUID.randomUUID();
|
||||
UUID member2 = UUID.randomUUID();
|
||||
UUID member3 = UUID.randomUUID();
|
||||
DecryptedGroup groupState = DecryptedGroup.newBuilder()
|
||||
.addMembers(member(member1))
|
||||
.addMembers(member(member3))
|
||||
.build();
|
||||
DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder()
|
||||
.addNewMembers(member(member1))
|
||||
.addNewMembers(member(member2))
|
||||
.addNewMembers(member(member3))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
DecryptedGroupChange expected = DecryptedGroupChange.newBuilder()
|
||||
.addNewMembers(member(member2))
|
||||
.build();
|
||||
assertEquals(expected, resolvedChanges);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_4__changes_to_remove_missing_members_are_excluded() {
|
||||
UUID member1 = UUID.randomUUID();
|
||||
UUID member2 = UUID.randomUUID();
|
||||
UUID member3 = UUID.randomUUID();
|
||||
DecryptedGroup groupState = DecryptedGroup.newBuilder()
|
||||
.addMembers(member(member2))
|
||||
.build();
|
||||
DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder()
|
||||
.addDeleteMembers(UuidUtil.toByteString(member1))
|
||||
.addDeleteMembers(UuidUtil.toByteString(member2))
|
||||
.addDeleteMembers(UuidUtil.toByteString(member3))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
DecryptedGroupChange expected = DecryptedGroupChange.newBuilder()
|
||||
.addDeleteMembers(UuidUtil.toByteString(member2))
|
||||
.build();
|
||||
|
||||
assertEquals(expected, resolvedChanges);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_5__role_change_is_preserved() {
|
||||
UUID member1 = UUID.randomUUID();
|
||||
UUID member2 = UUID.randomUUID();
|
||||
UUID member3 = UUID.randomUUID();
|
||||
DecryptedGroup groupState = DecryptedGroup.newBuilder()
|
||||
.addMembers(admin(member1))
|
||||
.addMembers(member(member2))
|
||||
.addMembers(member(member3))
|
||||
.build();
|
||||
DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder()
|
||||
.addModifyMemberRoles(demoteAdmin(member1))
|
||||
.addModifyMemberRoles(promoteAdmin(member2))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
assertEquals(decryptedChange, resolvedChanges);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_5__unnecessary_role_changes_removed() {
|
||||
UUID member1 = UUID.randomUUID();
|
||||
UUID member2 = UUID.randomUUID();
|
||||
UUID member3 = UUID.randomUUID();
|
||||
UUID memberNotInGroup = UUID.randomUUID();
|
||||
DecryptedGroup groupState = DecryptedGroup.newBuilder()
|
||||
.addMembers(admin(member1))
|
||||
.addMembers(member(member2))
|
||||
.addMembers(member(member3))
|
||||
.build();
|
||||
DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder()
|
||||
.addModifyMemberRoles(promoteAdmin(member1))
|
||||
.addModifyMemberRoles(promoteAdmin(member2))
|
||||
.addModifyMemberRoles(demoteAdmin(member3))
|
||||
.addModifyMemberRoles(promoteAdmin(memberNotInGroup))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
|
||||
DecryptedGroupChange expected = DecryptedGroupChange.newBuilder()
|
||||
.addModifyMemberRoles(promoteAdmin(member2))
|
||||
.build();
|
||||
|
||||
assertEquals(expected, resolvedChanges);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_6__profile_key_changes() {
|
||||
UUID member1 = UUID.randomUUID();
|
||||
UUID member2 = UUID.randomUUID();
|
||||
UUID member3 = UUID.randomUUID();
|
||||
UUID memberNotInGroup = UUID.randomUUID();
|
||||
ProfileKey profileKey1 = randomProfileKey();
|
||||
ProfileKey profileKey2 = randomProfileKey();
|
||||
ProfileKey profileKey3 = randomProfileKey();
|
||||
ProfileKey profileKey4 = randomProfileKey();
|
||||
ProfileKey profileKey2b = randomProfileKey();
|
||||
DecryptedGroup groupState = DecryptedGroup.newBuilder()
|
||||
.addMembers(member(member1, profileKey1))
|
||||
.addMembers(member(member2, profileKey2))
|
||||
.addMembers(member(member3, profileKey3))
|
||||
.build();
|
||||
DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder()
|
||||
.addModifiedProfileKeys(member(member1, profileKey1))
|
||||
.addModifiedProfileKeys(member(member2, profileKey2b))
|
||||
.addModifiedProfileKeys(member(member3, profileKey3))
|
||||
.addModifiedProfileKeys(member(memberNotInGroup, profileKey4))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
DecryptedGroupChange expected = DecryptedGroupChange.newBuilder()
|
||||
.addModifiedProfileKeys(member(member2, profileKey2b))
|
||||
.build();
|
||||
|
||||
assertEquals(expected, resolvedChanges);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_7__add_pending_members() {
|
||||
UUID member1 = UUID.randomUUID();
|
||||
UUID member2 = UUID.randomUUID();
|
||||
UUID member3 = UUID.randomUUID();
|
||||
DecryptedGroup groupState = DecryptedGroup.newBuilder()
|
||||
.addMembers(member(member1))
|
||||
.addPendingMembers(pendingMember(member3))
|
||||
.build();
|
||||
DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder()
|
||||
.addNewPendingMembers(pendingMember(member1))
|
||||
.addNewPendingMembers(pendingMember(member2))
|
||||
.addNewPendingMembers(pendingMember(member3))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
DecryptedGroupChange expected = DecryptedGroupChange.newBuilder()
|
||||
.addNewPendingMembers(pendingMember(member2))
|
||||
.build();
|
||||
|
||||
assertEquals(expected, resolvedChanges);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_8__delete_pending_members() {
|
||||
UUID member1 = UUID.randomUUID();
|
||||
UUID member2 = UUID.randomUUID();
|
||||
UUID member3 = UUID.randomUUID();
|
||||
DecryptedGroup groupState = DecryptedGroup.newBuilder()
|
||||
.addMembers(member(member1))
|
||||
.addPendingMembers(pendingMember(member2))
|
||||
.build();
|
||||
DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder()
|
||||
.addDeletePendingMembers(pendingMemberRemoval(member1))
|
||||
.addDeletePendingMembers(pendingMemberRemoval(member2))
|
||||
.addDeletePendingMembers(pendingMemberRemoval(member3))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
DecryptedGroupChange expected = DecryptedGroupChange.newBuilder()
|
||||
.addDeletePendingMembers(pendingMemberRemoval(member2))
|
||||
.build();
|
||||
|
||||
assertEquals(expected, resolvedChanges);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_9__promote_pending_members() {
|
||||
UUID member1 = UUID.randomUUID();
|
||||
UUID member2 = UUID.randomUUID();
|
||||
UUID member3 = UUID.randomUUID();
|
||||
ProfileKey profileKey2 = randomProfileKey();
|
||||
DecryptedGroup groupState = DecryptedGroup.newBuilder()
|
||||
.addMembers(member(member1))
|
||||
.addPendingMembers(pendingMember(member2))
|
||||
.build();
|
||||
DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder()
|
||||
.addPromotePendingMembers(member(member1))
|
||||
.addPromotePendingMembers(member(member2, profileKey2))
|
||||
.addPromotePendingMembers(member(member3))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
DecryptedGroupChange expected = DecryptedGroupChange.newBuilder()
|
||||
.addPromotePendingMembers(member(member2, profileKey2))
|
||||
.build();
|
||||
assertEquals(expected, resolvedChanges);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_3_to_9__add_of_pending_member_converted_to_a_promote() {
|
||||
UUID member1 = UUID.randomUUID();
|
||||
DecryptedGroup groupState = DecryptedGroup.newBuilder()
|
||||
.addPendingMembers(pendingMember(member1))
|
||||
.build();
|
||||
DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder()
|
||||
.addNewMembers(member(member1))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
DecryptedGroupChange expected = DecryptedGroupChange.newBuilder()
|
||||
.addPromotePendingMembers(member(member1))
|
||||
.build();
|
||||
|
||||
assertEquals(expected, resolvedChanges);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_10__title_change_is_preserved() {
|
||||
DecryptedGroup groupState = DecryptedGroup.newBuilder()
|
||||
.setTitle("Existing title")
|
||||
.build();
|
||||
DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder()
|
||||
.setNewTitle(DecryptedString.newBuilder().setValue("New title").build())
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
assertEquals(decryptedChange, resolvedChanges);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_10__no_title_change_is_removed() {
|
||||
DecryptedGroup groupState = DecryptedGroup.newBuilder()
|
||||
.setTitle("Existing title")
|
||||
.build();
|
||||
DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder()
|
||||
.setNewTitle(DecryptedString.newBuilder().setValue("Existing title").build())
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
assertTrue(DecryptedGroupUtil.changeIsEmpty(resolvedChanges));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_11__avatar_change_is_preserved() {
|
||||
DecryptedGroup groupState = DecryptedGroup.newBuilder()
|
||||
.setAvatar("Existing avatar")
|
||||
.build();
|
||||
DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder()
|
||||
.setNewAvatar(DecryptedString.newBuilder().setValue("New avatar").build())
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
assertEquals(decryptedChange, resolvedChanges);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_11__no_avatar_change_is_removed() {
|
||||
DecryptedGroup groupState = DecryptedGroup.newBuilder()
|
||||
.setAvatar("Existing avatar")
|
||||
.build();
|
||||
DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder()
|
||||
.setNewAvatar(DecryptedString.newBuilder().setValue("Existing avatar").build())
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
assertTrue(DecryptedGroupUtil.changeIsEmpty(resolvedChanges));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_12__timer_change_is_preserved() {
|
||||
DecryptedGroup groupState = DecryptedGroup.newBuilder()
|
||||
.setDisappearingMessagesTimer(DecryptedTimer.newBuilder().setDuration(123))
|
||||
.build();
|
||||
DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder()
|
||||
.setNewTimer(DecryptedTimer.newBuilder().setDuration(456))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
assertEquals(decryptedChange, resolvedChanges);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_12__no_timer_change_is_removed() {
|
||||
DecryptedGroup groupState = DecryptedGroup.newBuilder()
|
||||
.setDisappearingMessagesTimer(DecryptedTimer.newBuilder().setDuration(123))
|
||||
.build();
|
||||
DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder()
|
||||
.setNewTimer(DecryptedTimer.newBuilder().setDuration(123))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
assertTrue(DecryptedGroupUtil.changeIsEmpty(resolvedChanges));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_13__attribute_access_change_is_preserved() {
|
||||
DecryptedGroup groupState = DecryptedGroup.newBuilder()
|
||||
.setAccessControl(AccessControl.newBuilder().setAttributes(AccessControl.AccessRequired.ADMINISTRATOR))
|
||||
.build();
|
||||
DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder()
|
||||
.setNewAttributeAccess(AccessControl.AccessRequired.MEMBER)
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
assertEquals(decryptedChange, resolvedChanges);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_13__no_attribute_access_change_is_removed() {
|
||||
DecryptedGroup groupState = DecryptedGroup.newBuilder()
|
||||
.setAccessControl(AccessControl.newBuilder().setAttributes(AccessControl.AccessRequired.ADMINISTRATOR))
|
||||
.build();
|
||||
DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder()
|
||||
.setNewAttributeAccess(AccessControl.AccessRequired.ADMINISTRATOR)
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
assertTrue(DecryptedGroupUtil.changeIsEmpty(resolvedChanges));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_14__membership_access_change_is_preserved() {
|
||||
DecryptedGroup groupState = DecryptedGroup.newBuilder()
|
||||
.setAccessControl(AccessControl.newBuilder().setMembers(AccessControl.AccessRequired.ADMINISTRATOR))
|
||||
.build();
|
||||
DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder()
|
||||
.setNewMemberAccess(AccessControl.AccessRequired.MEMBER)
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
assertEquals(decryptedChange, resolvedChanges);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_14__no_membership_access_change_is_removed() {
|
||||
DecryptedGroup groupState = DecryptedGroup.newBuilder()
|
||||
.setAccessControl(AccessControl.newBuilder().setMembers(AccessControl.AccessRequired.ADMINISTRATOR))
|
||||
.build();
|
||||
DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder()
|
||||
.setNewMemberAccess(AccessControl.AccessRequired.ADMINISTRATOR)
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
assertTrue(DecryptedGroupUtil.changeIsEmpty(resolvedChanges));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_15__no_membership_access_change_is_removed() {
|
||||
DecryptedGroup groupState = DecryptedGroup.newBuilder()
|
||||
.setAccessControl(AccessControl.newBuilder().setAddFromInviteLink(AccessControl.AccessRequired.ADMINISTRATOR))
|
||||
.build();
|
||||
DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder()
|
||||
.setNewInviteLinkAccess(AccessControl.AccessRequired.ADMINISTRATOR)
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
assertTrue(DecryptedGroupUtil.changeIsEmpty(resolvedChanges));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_16__changes_to_add_requesting_members_when_full_members_are_removed() {
|
||||
UUID member1 = UUID.randomUUID();
|
||||
UUID member2 = UUID.randomUUID();
|
||||
UUID member3 = UUID.randomUUID();
|
||||
ProfileKey profileKey2 = randomProfileKey();
|
||||
DecryptedGroup groupState = DecryptedGroup.newBuilder()
|
||||
.addMembers(member(member1))
|
||||
.addMembers(member(member3))
|
||||
.build();
|
||||
DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder()
|
||||
.addNewRequestingMembers(requestingMember(member1))
|
||||
.addNewRequestingMembers(requestingMember(member2, profileKey2))
|
||||
.addNewRequestingMembers(requestingMember(member3))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
DecryptedGroupChange expected = DecryptedGroupChange.newBuilder()
|
||||
.addNewRequestingMembers(requestingMember(member2, profileKey2))
|
||||
.build();
|
||||
|
||||
assertEquals(expected, resolvedChanges);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_16__changes_to_add_requesting_members_when_pending_are_promoted() {
|
||||
UUID member1 = UUID.randomUUID();
|
||||
UUID member2 = UUID.randomUUID();
|
||||
UUID member3 = UUID.randomUUID();
|
||||
ProfileKey profileKey1 = randomProfileKey();
|
||||
ProfileKey profileKey2 = randomProfileKey();
|
||||
ProfileKey profileKey3 = randomProfileKey();
|
||||
DecryptedGroup groupState = DecryptedGroup.newBuilder()
|
||||
.addPendingMembers(pendingMember(member1))
|
||||
.addPendingMembers(pendingMember(member3))
|
||||
.build();
|
||||
DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder()
|
||||
.addNewRequestingMembers(requestingMember(member1, profileKey1))
|
||||
.addNewRequestingMembers(requestingMember(member2, profileKey2))
|
||||
.addNewRequestingMembers(requestingMember(member3, profileKey3))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
DecryptedGroupChange expected = DecryptedGroupChange.newBuilder()
|
||||
.addPromotePendingMembers(member(member1, profileKey1))
|
||||
.addNewRequestingMembers(requestingMember(member2, profileKey2))
|
||||
.addPromotePendingMembers(member(member3, profileKey3))
|
||||
.build();
|
||||
|
||||
assertEquals(expected, resolvedChanges);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_17__changes_to_remove_missing_requesting_members_are_excluded() {
|
||||
UUID member1 = UUID.randomUUID();
|
||||
UUID member2 = UUID.randomUUID();
|
||||
UUID member3 = UUID.randomUUID();
|
||||
DecryptedGroup groupState = DecryptedGroup.newBuilder()
|
||||
.addRequestingMembers(requestingMember(member2))
|
||||
.build();
|
||||
DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder()
|
||||
.addDeleteRequestingMembers(UuidUtil.toByteString(member1))
|
||||
.addDeleteRequestingMembers(UuidUtil.toByteString(member2))
|
||||
.addDeleteRequestingMembers(UuidUtil.toByteString(member3))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
DecryptedGroupChange expected = DecryptedGroupChange.newBuilder()
|
||||
.addDeleteRequestingMembers(UuidUtil.toByteString(member2))
|
||||
.build();
|
||||
|
||||
assertEquals(expected, resolvedChanges);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_18__promote_requesting_members() {
|
||||
UUID member1 = UUID.randomUUID();
|
||||
UUID member2 = UUID.randomUUID();
|
||||
UUID member3 = UUID.randomUUID();
|
||||
DecryptedGroup groupState = DecryptedGroup.newBuilder()
|
||||
.addMembers(member(member1))
|
||||
.addRequestingMembers(requestingMember(member2))
|
||||
.build();
|
||||
DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder()
|
||||
.addPromoteRequestingMembers(approveMember(member1))
|
||||
.addPromoteRequestingMembers(approveMember(member2))
|
||||
.addPromoteRequestingMembers(approveMember(member3))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
DecryptedGroupChange expected = DecryptedGroupChange.newBuilder()
|
||||
.addPromoteRequestingMembers(approveMember(member2))
|
||||
.build();
|
||||
|
||||
assertEquals(expected, resolvedChanges);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_19__password_change_is_kept() {
|
||||
ByteString password1 = ByteString.copyFrom(Util.getSecretBytes(16));
|
||||
ByteString password2 = ByteString.copyFrom(Util.getSecretBytes(16));
|
||||
DecryptedGroup groupState = DecryptedGroup.newBuilder()
|
||||
.setInviteLinkPassword(password1)
|
||||
.build();
|
||||
DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder()
|
||||
.setNewInviteLinkPassword(password2)
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
assertEquals(decryptedChange, resolvedChanges);
|
||||
}
|
||||
}
|
@ -12,6 +12,8 @@ import org.whispersystems.signalservice.internal.util.Util;
|
||||
import org.whispersystems.signalservice.testutil.ZkGroupLibraryUtil;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtobufTestUtils.getMaxDeclaredFieldNumber;
|
||||
|
||||
public final class GroupsV2Operations_decrypt_groupJoinInfo_Test {
|
||||
@ -39,7 +41,7 @@ public final class GroupsV2Operations_decrypt_groupJoinInfo_Test {
|
||||
int maxFieldFound = getMaxDeclaredFieldNumber(GroupJoinInfo.class);
|
||||
|
||||
assertEquals("GroupOperations and its tests need updating to account for new fields on " + GroupJoinInfo.class.getName(),
|
||||
6, maxFieldFound);
|
||||
7, maxFieldFound);
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -107,4 +109,26 @@ public final class GroupsV2Operations_decrypt_groupJoinInfo_Test {
|
||||
|
||||
assertEquals(11, decryptedGroupJoinInfo.getRevision());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void pending_approval_passed_though_7_true() {
|
||||
GroupJoinInfo groupJoinInfo = GroupJoinInfo.newBuilder()
|
||||
.setPendingAdminApproval(true)
|
||||
.build();
|
||||
|
||||
DecryptedGroupJoinInfo decryptedGroupJoinInfo = groupOperations.decryptGroupJoinInfo(groupJoinInfo);
|
||||
|
||||
assertTrue(decryptedGroupJoinInfo.getPendingAdminApproval());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void pending_approval_passed_though_7_false() {
|
||||
GroupJoinInfo groupJoinInfo = GroupJoinInfo.newBuilder()
|
||||
.setPendingAdminApproval(false)
|
||||
.build();
|
||||
|
||||
DecryptedGroupJoinInfo decryptedGroupJoinInfo = groupOperations.decryptGroupJoinInfo(groupJoinInfo);
|
||||
|
||||
assertFalse(decryptedGroupJoinInfo.getPendingAdminApproval());
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user