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.StickerManagementActivity;
|
||||||
import org.thoughtcrime.securesms.stickers.StickerPackInstallEvent;
|
import org.thoughtcrime.securesms.stickers.StickerPackInstallEvent;
|
||||||
import org.thoughtcrime.securesms.stickers.StickerSearchRepository;
|
import org.thoughtcrime.securesms.stickers.StickerSearchRepository;
|
||||||
|
import org.thoughtcrime.securesms.util.AsynchronousCallback;
|
||||||
import org.thoughtcrime.securesms.util.Base64;
|
import org.thoughtcrime.securesms.util.Base64;
|
||||||
import org.thoughtcrime.securesms.util.BitmapUtil;
|
import org.thoughtcrime.securesms.util.BitmapUtil;
|
||||||
import org.thoughtcrime.securesms.util.CharacterCalculator.CharacterState;
|
import org.thoughtcrime.securesms.util.CharacterCalculator.CharacterState;
|
||||||
@ -356,6 +357,8 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
|||||||
private InputPanel inputPanel;
|
private InputPanel inputPanel;
|
||||||
private View panelParent;
|
private View panelParent;
|
||||||
private View noLongerMemberBanner;
|
private View noLongerMemberBanner;
|
||||||
|
private View requestingMemberBanner;
|
||||||
|
private View cancelJoinRequest;
|
||||||
private Stub<View> mentionsSuggestions;
|
private Stub<View> mentionsSuggestions;
|
||||||
|
|
||||||
private LinkPreviewViewModel linkPreviewViewModel;
|
private LinkPreviewViewModel linkPreviewViewModel;
|
||||||
@ -383,12 +386,21 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
|||||||
long threadId,
|
long threadId,
|
||||||
int distributionType,
|
int distributionType,
|
||||||
int startingPosition)
|
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 intent = new Intent(context, ConversationActivity.class);
|
||||||
intent.putExtra(ConversationActivity.RECIPIENT_EXTRA, recipientId);
|
intent.putExtra(ConversationActivity.RECIPIENT_EXTRA, recipientId);
|
||||||
intent.putExtra(ConversationActivity.THREAD_ID_EXTRA, threadId);
|
intent.putExtra(ConversationActivity.THREAD_ID_EXTRA, threadId);
|
||||||
intent.putExtra(ConversationActivity.DISTRIBUTION_TYPE_EXTRA, distributionType);
|
|
||||||
intent.putExtra(ConversationActivity.STARTING_POSITION_EXTRA, startingPosition);
|
|
||||||
|
|
||||||
return intent;
|
return intent;
|
||||||
}
|
}
|
||||||
@ -1452,14 +1464,64 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void initializeEnabledCheck() {
|
private void initializeEnabledCheck() {
|
||||||
groupViewModel.getGroupActiveState().observe(this, state -> {
|
groupViewModel.getSelfMemberLevel().observe(this, selfMemberShip -> {
|
||||||
boolean inactivePushGroup = state != null && isPushGroupConversation() && !state.isActiveGroup();
|
boolean canSendMessages;
|
||||||
boolean enabled = !inactivePushGroup;
|
boolean leftGroup;
|
||||||
noLongerMemberBanner.setVisibility(enabled ? View.GONE : View.VISIBLE);
|
boolean canCancelRequest;
|
||||||
inputPanel.setVisibility(enabled ? View.VISIBLE : View.GONE);
|
|
||||||
inputPanel.setEnabled(enabled);
|
if (selfMemberShip == null) {
|
||||||
sendButton.setEnabled(enabled);
|
leftGroup = false;
|
||||||
attachButton.setEnabled(enabled);
|
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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1754,7 +1816,9 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
|||||||
ImageButton quickCameraToggle = ViewUtil.findById(this, R.id.quick_camera_toggle);
|
ImageButton quickCameraToggle = ViewUtil.findById(this, R.id.quick_camera_toggle);
|
||||||
ImageButton inlineAttachmentButton = ViewUtil.findById(this, R.id.inline_attachment_button);
|
ImageButton inlineAttachmentButton = ViewUtil.findById(this, R.id.inline_attachment_button);
|
||||||
|
|
||||||
noLongerMemberBanner = findViewById(R.id.conversation_no_longer_member_banner);
|
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);
|
container.addOnKeyboardShownListener(this);
|
||||||
inputPanel.setListener(this);
|
inputPanel.setListener(this);
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
package org.thoughtcrime.securesms.conversation;
|
package org.thoughtcrime.securesms.conversation;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
|
import android.text.TextUtils;
|
||||||
import android.util.AttributeSet;
|
import android.util.AttributeSet;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.widget.TextView;
|
import android.widget.TextView;
|
||||||
@ -54,6 +55,7 @@ public class ConversationBannerView extends ConstraintLayout {
|
|||||||
|
|
||||||
public void setSubtitle(@Nullable CharSequence subtitle) {
|
public void setSubtitle(@Nullable CharSequence subtitle) {
|
||||||
contactSubtitle.setText(subtitle);
|
contactSubtitle.setText(subtitle);
|
||||||
|
contactSubtitle.setVisibility(TextUtils.isEmpty(subtitle) ? GONE : VISIBLE);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setDescription(@Nullable CharSequence description) {
|
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.ConversationAdapter.StickyHeaderViewHolder;
|
||||||
import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory;
|
import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory;
|
||||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||||
import org.thoughtcrime.securesms.database.MmsDatabase;
|
|
||||||
import org.thoughtcrime.securesms.database.MessageDatabase;
|
import org.thoughtcrime.securesms.database.MessageDatabase;
|
||||||
|
import org.thoughtcrime.securesms.database.MmsDatabase;
|
||||||
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
||||||
import org.thoughtcrime.securesms.database.SmsDatabase;
|
import org.thoughtcrime.securesms.database.SmsDatabase;
|
||||||
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
|
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
|
||||||
@ -402,9 +402,11 @@ public class ConversationFragment extends LoggingFragment {
|
|||||||
conversationBanner.setSubtitle(context.getResources()
|
conversationBanner.setSubtitle(context.getResources()
|
||||||
.getQuantityString(R.plurals.MessageRequestProfileView_members_and_invited, memberCount,
|
.getQuantityString(R.plurals.MessageRequestProfileView_members_and_invited, memberCount,
|
||||||
memberCount, pendingMemberCount));
|
memberCount, pendingMemberCount));
|
||||||
} else {
|
} else if (memberCount > 0) {
|
||||||
conversationBanner.setSubtitle(context.getResources().getQuantityString(R.plurals.MessageRequestProfileView_members, memberCount,
|
conversationBanner.setSubtitle(context.getResources().getQuantityString(R.plurals.MessageRequestProfileView_members, memberCount,
|
||||||
memberCount));
|
memberCount));
|
||||||
|
} else {
|
||||||
|
conversationBanner.setSubtitle(null);
|
||||||
}
|
}
|
||||||
} else if (isSelf) {
|
} else if (isSelf) {
|
||||||
conversationBanner.setSubtitle(context.getString(R.string.ConversationFragment__you_can_add_notes_for_yourself_in_this_conversation));
|
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;
|
||||||
import org.thoughtcrime.securesms.database.GroupDatabase.GroupRecord;
|
import org.thoughtcrime.securesms.database.GroupDatabase.GroupRecord;
|
||||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
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.recipients.Recipient;
|
||||||
|
import org.thoughtcrime.securesms.util.AsynchronousCallback;
|
||||||
|
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
|
||||||
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
|
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
|
||||||
|
|
||||||
class ConversationGroupViewModel extends ViewModel {
|
import java.io.IOException;
|
||||||
|
|
||||||
private final MutableLiveData<Recipient> liveRecipient;
|
final class ConversationGroupViewModel extends ViewModel {
|
||||||
private final LiveData<GroupActiveState> groupActiveState;
|
|
||||||
|
private final MutableLiveData<Recipient> liveRecipient;
|
||||||
|
private final LiveData<GroupActiveState> groupActiveState;
|
||||||
|
private final LiveData<GroupDatabase.MemberLevel> selfMembershipLevel;
|
||||||
|
|
||||||
private ConversationGroupViewModel() {
|
private ConversationGroupViewModel() {
|
||||||
liveRecipient = new MutableLiveData<>();
|
this.liveRecipient = new MutableLiveData<>();
|
||||||
LiveData<GroupRecord> groupRecord = LiveDataUtil.mapAsync(liveRecipient, this::getGroupRecordForRecipient);
|
|
||||||
groupActiveState = Transformations.distinctUntilChanged(Transformations.map(groupRecord, this::mapToGroupActiveState));
|
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) {
|
void onRecipientChange(Recipient recipient) {
|
||||||
@ -36,7 +48,11 @@ class ConversationGroupViewModel extends ViewModel {
|
|||||||
return groupActiveState;
|
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()) {
|
if (recipient != null && recipient.isGroup()) {
|
||||||
Application context = ApplicationDependencies.getApplication();
|
Application context = ApplicationDependencies.getApplication();
|
||||||
GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context);
|
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) {
|
if (record == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return new GroupActiveState(record.isActive(), record.isV2Group());
|
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 {
|
static final class GroupActiveState {
|
||||||
private final boolean isActive;
|
private final boolean isActive;
|
||||||
private final boolean isActiveV2;
|
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) {
|
Optional<GroupRecord> getGroup(Cursor cursor) {
|
||||||
Reader reader = new Reader(cursor);
|
Reader reader = new Reader(cursor);
|
||||||
return Optional.fromNullable(reader.getCurrent());
|
return Optional.fromNullable(reader.getCurrent());
|
||||||
@ -364,7 +373,13 @@ public final class GroupDatabase extends Database {
|
|||||||
|
|
||||||
contentValues.put(AVATAR_RELAY, relay);
|
contentValues.put(AVATAR_RELAY, relay);
|
||||||
contentValues.put(TIMESTAMP, System.currentTimeMillis());
|
contentValues.put(TIMESTAMP, System.currentTimeMillis());
|
||||||
contentValues.put(ACTIVE, 1);
|
|
||||||
|
if (groupId.isV2()) {
|
||||||
|
contentValues.put(ACTIVE, groupState != null && gv2GroupActive(groupState) ? 1 : 0);
|
||||||
|
} else {
|
||||||
|
contentValues.put(ACTIVE, 1);
|
||||||
|
}
|
||||||
|
|
||||||
contentValues.put(MMS, groupId.isMms());
|
contentValues.put(MMS, groupId.isMms());
|
||||||
|
|
||||||
if (groupMasterKey != null) {
|
if (groupMasterKey != null) {
|
||||||
@ -428,14 +443,12 @@ public final class GroupDatabase extends Database {
|
|||||||
RecipientId groupRecipientId = recipientDatabase.getOrInsertFromGroupId(groupId);
|
RecipientId groupRecipientId = recipientDatabase.getOrInsertFromGroupId(groupId);
|
||||||
String title = decryptedGroup.getTitle();
|
String title = decryptedGroup.getTitle();
|
||||||
ContentValues contentValues = new ContentValues();
|
ContentValues contentValues = new ContentValues();
|
||||||
UUID uuid = Recipient.self().getUuid().get();
|
|
||||||
|
|
||||||
contentValues.put(TITLE, title);
|
contentValues.put(TITLE, title);
|
||||||
contentValues.put(V2_REVISION, decryptedGroup.getRevision());
|
contentValues.put(V2_REVISION, decryptedGroup.getRevision());
|
||||||
contentValues.put(V2_DECRYPTED_GROUP, decryptedGroup.toByteArray());
|
contentValues.put(V2_DECRYPTED_GROUP, decryptedGroup.toByteArray());
|
||||||
contentValues.put(MEMBERS, serializeV2GroupMembers(decryptedGroup));
|
contentValues.put(MEMBERS, serializeV2GroupMembers(decryptedGroup));
|
||||||
contentValues.put(ACTIVE, DecryptedGroupUtil.findMemberByUuid(decryptedGroup.getMembersList(), uuid).isPresent() ||
|
contentValues.put(ACTIVE, gv2GroupActive(decryptedGroup) ? 1 : 0);
|
||||||
DecryptedGroupUtil.findPendingByUuid(decryptedGroup.getPendingMembersList(), uuid).isPresent() ? 1 : 0);
|
|
||||||
|
|
||||||
databaseHelper.getWritableDatabase().update(TABLE_NAME, contentValues,
|
databaseHelper.getWritableDatabase().update(TABLE_NAME, contentValues,
|
||||||
GROUP_ID + " = ?",
|
GROUP_ID + " = ?",
|
||||||
@ -502,6 +515,13 @@ public final class GroupDatabase extends Database {
|
|||||||
Recipient.live(groupRecipient).refresh();
|
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) {
|
private List<RecipientId> getCurrentMembers(@NonNull GroupId groupId) {
|
||||||
Cursor cursor = null;
|
Cursor cursor = null;
|
||||||
|
|
||||||
@ -843,8 +863,10 @@ public final class GroupDatabase extends Database {
|
|||||||
? MemberLevel.ADMINISTRATOR
|
? MemberLevel.ADMINISTRATOR
|
||||||
: MemberLevel.FULL_MEMBER)
|
: MemberLevel.FULL_MEMBER)
|
||||||
.or(() -> DecryptedGroupUtil.findPendingByUuid(decryptedGroup.getPendingMembersList(), recipient.getUuid().get())
|
.or(() -> DecryptedGroupUtil.findPendingByUuid(decryptedGroup.getPendingMembersList(), recipient.getUuid().get())
|
||||||
.isPresent() ? MemberLevel.PENDING_MEMBER
|
.transform(m -> MemberLevel.PENDING_MEMBER)
|
||||||
: MemberLevel.NOT_A_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) {
|
public List<Recipient> getMemberRecipients(@NonNull MemberSet memberSet) {
|
||||||
@ -902,6 +924,7 @@ public final class GroupDatabase extends Database {
|
|||||||
public enum MemberLevel {
|
public enum MemberLevel {
|
||||||
NOT_A_MEMBER(false),
|
NOT_A_MEMBER(false),
|
||||||
PENDING_MEMBER(false),
|
PENDING_MEMBER(false),
|
||||||
|
REQUESTING_MEMBER(false),
|
||||||
FULL_MEMBER(true),
|
FULL_MEMBER(true),
|
||||||
ADMINISTRATOR(true);
|
ADMINISTRATOR(true);
|
||||||
|
|
||||||
|
@ -584,7 +584,13 @@ final class GroupsV2UpdateMessageProducer {
|
|||||||
if (requestingMemberIsYou) {
|
if (requestingMemberIsYou) {
|
||||||
updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_approved_your_request_to_join_the_group, editor)));
|
updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_approved_your_request_to_join_the_group, editor)));
|
||||||
} else {
|
} 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)));
|
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)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -602,13 +608,25 @@ final class GroupsV2UpdateMessageProducer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void describeRequestingMembersDeletes(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
|
private void describeRequestingMembersDeletes(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
|
||||||
|
boolean editorIsYou = change.getEditor().equals(selfUuidBytes);
|
||||||
|
|
||||||
for (ByteString requestingMember : change.getDeleteRequestingMembersList()) {
|
for (ByteString requestingMember : change.getDeleteRequestingMembersList()) {
|
||||||
boolean requestingMemberIsYou = requestingMember.equals(selfUuidBytes);
|
boolean requestingMemberIsYou = requestingMember.equals(selfUuidBytes);
|
||||||
|
|
||||||
if (requestingMemberIsYou) {
|
if (requestingMemberIsYou) {
|
||||||
updates.add(updateDescription(context.getString(R.string.MessageRecord_your_request_to_join_the_group_has_been_denied_by_an_admin)));
|
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 {
|
} 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)));
|
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)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -174,7 +174,7 @@ public abstract class MessageRecord extends DisplayRecord {
|
|||||||
DecryptedGroupV2Context decryptedGroupV2Context = DecryptedGroupV2Context.parseFrom(decoded);
|
DecryptedGroupV2Context decryptedGroupV2Context = DecryptedGroupV2Context.parseFrom(decoded);
|
||||||
GroupsV2UpdateMessageProducer updateMessageProducer = new GroupsV2UpdateMessageProducer(context, descriptionStrategy, Recipient.self().getUuid().get());
|
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()));
|
return UpdateDescription.concatWithNewLines(updateMessageProducer.describeChanges(decryptedGroupV2Context.getChange()));
|
||||||
} else {
|
} else {
|
||||||
return updateMessageProducer.describeNewGroup(decryptedGroupV2Context.getGroupState());
|
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
|
@WorkerThread
|
||||||
public static void updateGroupFromServer(@NonNull Context context,
|
public static void updateGroupFromServer(@NonNull Context context,
|
||||||
@NonNull GroupMasterKey groupMasterKey,
|
@NonNull GroupMasterKey groupMasterKey,
|
||||||
@ -174,6 +180,11 @@ public final class GroupManager {
|
|||||||
public static void updateSelfProfileKeyInGroup(@NonNull Context context, @NonNull GroupId.V2 groupId)
|
public static void updateSelfProfileKeyInGroup(@NonNull Context context, @NonNull GroupId.V2 groupId)
|
||||||
throws IOException, GroupChangeBusyException, GroupInsufficientRightsException, GroupNotAMemberException, GroupChangeFailedException
|
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())) {
|
try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) {
|
||||||
editor.updateSelfProfileKeyInGroup();
|
editor.updateSelfProfileKeyInGroup();
|
||||||
}
|
}
|
||||||
@ -266,12 +277,35 @@ public final class GroupManager {
|
|||||||
@WorkerThread
|
@WorkerThread
|
||||||
public static @NonNull DecryptedGroupJoinInfo getGroupJoinInfoFromServer(@NonNull Context context,
|
public static @NonNull DecryptedGroupJoinInfo getGroupJoinInfoFromServer(@NonNull Context context,
|
||||||
@NonNull GroupMasterKey groupMasterKey,
|
@NonNull GroupMasterKey groupMasterKey,
|
||||||
@NonNull GroupLinkPassword groupLinkPassword)
|
@Nullable GroupLinkPassword groupLinkPassword)
|
||||||
throws IOException, VerificationFailedException, GroupLinkNotActiveException
|
throws IOException, VerificationFailedException, GroupLinkNotActiveException
|
||||||
{
|
{
|
||||||
return new GroupManagerV2(context).getGroupJoinInfoFromServer(groupMasterKey, groupLinkPassword);
|
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 {
|
public static class GroupActionResult {
|
||||||
private final Recipient groupRecipient;
|
private final Recipient groupRecipient;
|
||||||
private final long threadId;
|
private final long threadId;
|
||||||
|
@ -6,7 +6,9 @@ import androidx.annotation.NonNull;
|
|||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import androidx.annotation.WorkerThread;
|
import androidx.annotation.WorkerThread;
|
||||||
|
|
||||||
|
import com.annimon.stream.Collectors;
|
||||||
import com.annimon.stream.Stream;
|
import com.annimon.stream.Stream;
|
||||||
|
import com.google.protobuf.ByteString;
|
||||||
import com.google.protobuf.InvalidProtocolBufferException;
|
import com.google.protobuf.InvalidProtocolBufferException;
|
||||||
|
|
||||||
import org.signal.storageservice.protos.groups.AccessControl;
|
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.DecryptedGroupJoinInfo;
|
||||||
import org.signal.storageservice.protos.groups.local.DecryptedMember;
|
import org.signal.storageservice.protos.groups.local.DecryptedMember;
|
||||||
import org.signal.storageservice.protos.groups.local.DecryptedPendingMember;
|
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.InvalidInputException;
|
||||||
import org.signal.zkgroup.VerificationFailedException;
|
import org.signal.zkgroup.VerificationFailedException;
|
||||||
import org.signal.zkgroup.groups.GroupMasterKey;
|
import org.signal.zkgroup.groups.GroupMasterKey;
|
||||||
import org.signal.zkgroup.groups.GroupSecretParams;
|
import org.signal.zkgroup.groups.GroupSecretParams;
|
||||||
import org.signal.zkgroup.groups.UuidCiphertext;
|
import org.signal.zkgroup.groups.UuidCiphertext;
|
||||||
import org.signal.zkgroup.profiles.ProfileKey;
|
import org.signal.zkgroup.profiles.ProfileKey;
|
||||||
|
import org.signal.zkgroup.profiles.ProfileKeyCredential;
|
||||||
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
|
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
|
||||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||||
import org.thoughtcrime.securesms.database.GroupDatabase;
|
import org.thoughtcrime.securesms.database.GroupDatabase;
|
||||||
|
import org.thoughtcrime.securesms.database.ThreadDatabase;
|
||||||
import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context;
|
import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context;
|
||||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||||
import org.thoughtcrime.securesms.groups.v2.GroupCandidateHelper;
|
import org.thoughtcrime.securesms.groups.v2.GroupCandidateHelper;
|
||||||
import org.thoughtcrime.securesms.groups.v2.GroupLinkPassword;
|
import org.thoughtcrime.securesms.groups.v2.GroupLinkPassword;
|
||||||
import org.thoughtcrime.securesms.groups.v2.processing.GroupsV2StateProcessor;
|
import org.thoughtcrime.securesms.groups.v2.processing.GroupsV2StateProcessor;
|
||||||
import org.thoughtcrime.securesms.jobs.PushGroupSilentUpdateSendJob;
|
import org.thoughtcrime.securesms.jobs.PushGroupSilentUpdateSendJob;
|
||||||
|
import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob;
|
||||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||||
import org.thoughtcrime.securesms.logging.Log;
|
import org.thoughtcrime.securesms.logging.Log;
|
||||||
import org.thoughtcrime.securesms.mms.OutgoingGroupUpdateMessage;
|
import org.thoughtcrime.securesms.mms.OutgoingGroupUpdateMessage;
|
||||||
@ -62,6 +68,7 @@ import java.util.Collection;
|
|||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@ -89,12 +96,14 @@ final class GroupManagerV2 {
|
|||||||
this.groupCandidateHelper = new GroupCandidateHelper(context);
|
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
|
throws IOException, VerificationFailedException, GroupLinkNotActiveException
|
||||||
{
|
{
|
||||||
GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
|
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
|
@WorkerThread
|
||||||
@ -107,17 +116,30 @@ final class GroupManagerV2 {
|
|||||||
return new GroupEditor(groupId, GroupsV2ProcessingLock.acquireGroupProcessingLock());
|
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
|
@WorkerThread
|
||||||
GroupUpdater updater(@NonNull GroupMasterKey groupId) throws GroupChangeBusyException {
|
GroupUpdater updater(@NonNull GroupMasterKey groupId) throws GroupChangeBusyException {
|
||||||
return new GroupUpdater(groupId, GroupsV2ProcessingLock.acquireGroupProcessingLock());
|
return new GroupUpdater(groupId, GroupsV2ProcessingLock.acquireGroupProcessingLock());
|
||||||
}
|
}
|
||||||
|
|
||||||
class GroupCreator implements Closeable {
|
final class GroupCreator extends LockOwner {
|
||||||
|
|
||||||
private final Closeable lock;
|
|
||||||
|
|
||||||
GroupCreator(@NonNull Closeable lock) {
|
GroupCreator(@NonNull Closeable lock) {
|
||||||
this.lock = lock;
|
super(lock);
|
||||||
}
|
}
|
||||||
|
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
@ -176,26 +198,21 @@ final class GroupManagerV2 {
|
|||||||
throw new GroupChangeFailedException(e);
|
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 GroupId.V2 groupId;
|
||||||
private final GroupMasterKey groupMasterKey;
|
private final GroupMasterKey groupMasterKey;
|
||||||
private final GroupSecretParams groupSecretParams;
|
private final GroupSecretParams groupSecretParams;
|
||||||
private final GroupsV2Operations.GroupOperations groupOperations;
|
private final GroupsV2Operations.GroupOperations groupOperations;
|
||||||
|
|
||||||
GroupEditor(@NonNull GroupId.V2 groupId, @NonNull Closeable lock) {
|
GroupEditor(@NonNull GroupId.V2 groupId, @NonNull Closeable lock) {
|
||||||
|
super(lock);
|
||||||
|
|
||||||
GroupDatabase.GroupRecord groupRecord = groupDatabase.requireGroup(groupId);
|
GroupDatabase.GroupRecord groupRecord = groupDatabase.requireGroup(groupId);
|
||||||
GroupDatabase.V2GroupProperties v2GroupProperties = groupRecord.requireV2GroupProperties();
|
GroupDatabase.V2GroupProperties v2GroupProperties = groupRecord.requireV2GroupProperties();
|
||||||
|
|
||||||
this.lock = lock;
|
|
||||||
this.groupId = groupId;
|
this.groupId = groupId;
|
||||||
this.groupMasterKey = v2GroupProperties.getGroupMasterKey();
|
this.groupMasterKey = v2GroupProperties.getGroupMasterKey();
|
||||||
this.groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
|
this.groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
|
||||||
@ -275,6 +292,17 @@ final class GroupManagerV2 {
|
|||||||
return commitChangeWithConflictResolution(groupOperations.createRemoveInvitationChange(new HashSet<>(uuidCipherTexts)));
|
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
|
@WorkerThread
|
||||||
@NonNull GroupManager.GroupActionResult setMemberAdmin(@NonNull RecipientId recipientId,
|
@NonNull GroupManager.GroupActionResult setMemberAdmin(@NonNull RecipientId recipientId,
|
||||||
boolean admin)
|
boolean admin)
|
||||||
@ -333,7 +361,7 @@ final class GroupManagerV2 {
|
|||||||
Optional<DecryptedMember> selfInGroup = DecryptedGroupUtil.findMemberByUuid(group.getMembersList(), selfUuid);
|
Optional<DecryptedMember> selfInGroup = DecryptedGroupUtil.findMemberByUuid(group.getMembersList(), selfUuid);
|
||||||
|
|
||||||
if (!selfInGroup.isPresent()) {
|
if (!selfInGroup.isPresent()) {
|
||||||
Log.w(TAG, "Self not in group");
|
Log.w(TAG, "Self not in group " + groupId);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -456,7 +484,7 @@ final class GroupManagerV2 {
|
|||||||
throws GroupNotAMemberException, GroupChangeFailedException, IOException, GroupInsufficientRightsException
|
throws GroupNotAMemberException, GroupChangeFailedException, IOException, GroupInsufficientRightsException
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
return groupsV2Api.patchGroup(change, authorization.getAuthorizationForToday(selfUuid, groupSecretParams));
|
return groupsV2Api.patchGroup(change, authorization.getAuthorizationForToday(selfUuid, groupSecretParams), Optional.absent());
|
||||||
} catch (NotInGroupException e) {
|
} catch (NotInGroupException e) {
|
||||||
Log.w(TAG, e);
|
Log.w(TAG, e);
|
||||||
throw new GroupNotAMemberException(e);
|
throw new GroupNotAMemberException(e);
|
||||||
@ -468,20 +496,15 @@ final class GroupManagerV2 {
|
|||||||
throw new GroupChangeFailedException(e);
|
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;
|
private final GroupMasterKey groupMasterKey;
|
||||||
|
|
||||||
GroupUpdater(@NonNull GroupMasterKey groupMasterKey, @NonNull Closeable lock) {
|
GroupUpdater(@NonNull GroupMasterKey groupMasterKey, @NonNull Closeable lock) {
|
||||||
this.lock = lock;
|
super(lock);
|
||||||
|
|
||||||
this.groupMasterKey = groupMasterKey;
|
this.groupMasterKey = groupMasterKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -507,6 +530,377 @@ final class GroupManagerV2 {
|
|||||||
|
|
||||||
return null;
|
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
|
@Override
|
||||||
public void close() throws IOException {
|
public void close() throws IOException {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
package org.thoughtcrime.securesms.groups.ui;
|
package org.thoughtcrime.securesms.groups.ui;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.Nullable;
|
||||||
import androidx.annotation.StringRes;
|
import androidx.annotation.StringRes;
|
||||||
|
|
||||||
import org.thoughtcrime.securesms.R;
|
import org.thoughtcrime.securesms.R;
|
||||||
@ -9,7 +9,11 @@ public final class GroupErrors {
|
|||||||
private 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) {
|
switch (failureReason) {
|
||||||
case NO_RIGHTS : return R.string.ManageGroupActivity_you_dont_have_the_rights_to_do_this;
|
case NO_RIGHTS : return R.string.ManageGroupActivity_you_dont_have_the_rights_to_do_this;
|
||||||
case NOT_CAPABLE : return R.string.ManageGroupActivity_not_capable;
|
case NOT_CAPABLE : return R.string.ManageGroupActivity_not_capable;
|
||||||
|
@ -1,42 +1,39 @@
|
|||||||
package org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining;
|
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 {
|
public final class GroupDetails {
|
||||||
private final String groupName;
|
private final DecryptedGroupJoinInfo joinInfo;
|
||||||
private final byte[] avatarBytes;
|
private final byte[] avatarBytes;
|
||||||
private final int groupMembershipCount;
|
|
||||||
private final boolean requiresAdminApproval;
|
|
||||||
private final int groupRevision;
|
|
||||||
|
|
||||||
public GroupDetails(String groupName,
|
public GroupDetails(@NonNull DecryptedGroupJoinInfo joinInfo,
|
||||||
byte[] avatarBytes,
|
@Nullable byte[] avatarBytes)
|
||||||
int groupMembershipCount,
|
|
||||||
boolean requiresAdminApproval,
|
|
||||||
int groupRevision)
|
|
||||||
{
|
{
|
||||||
this.groupName = groupName;
|
this.joinInfo = joinInfo;
|
||||||
this.avatarBytes = avatarBytes;
|
this.avatarBytes = avatarBytes;
|
||||||
this.groupMembershipCount = groupMembershipCount;
|
|
||||||
this.requiresAdminApproval = requiresAdminApproval;
|
|
||||||
this.groupRevision = groupRevision;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getGroupName() {
|
public @NonNull String getGroupName() {
|
||||||
return groupName;
|
return joinInfo.getTitle();
|
||||||
}
|
}
|
||||||
|
|
||||||
public byte[] getAvatarBytes() {
|
public @Nullable byte[] getAvatarBytes() {
|
||||||
return avatarBytes;
|
return avatarBytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public @NonNull DecryptedGroupJoinInfo getJoinInfo() {
|
||||||
|
return joinInfo;
|
||||||
|
}
|
||||||
|
|
||||||
public int getGroupMembershipCount() {
|
public int getGroupMembershipCount() {
|
||||||
return groupMembershipCount;
|
return joinInfo.getMemberCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean joinRequiresAdminApproval() {
|
public boolean joinRequiresAdminApproval() {
|
||||||
return requiresAdminApproval;
|
return joinInfo.getAddFromInviteLink() == AccessControl.AccessRequired.ADMINISTRATOR;
|
||||||
}
|
|
||||||
|
|
||||||
public int getGroupRevision() {
|
|
||||||
return groupRevision;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
package org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining;
|
package org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining;
|
||||||
|
|
||||||
|
import android.content.Intent;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.view.LayoutInflater;
|
import android.view.LayoutInflater;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
@ -22,7 +23,9 @@ import org.thoughtcrime.securesms.color.MaterialColor;
|
|||||||
import org.thoughtcrime.securesms.components.AvatarImageView;
|
import org.thoughtcrime.securesms.components.AvatarImageView;
|
||||||
import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto;
|
import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto;
|
||||||
import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto;
|
import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto;
|
||||||
|
import org.thoughtcrime.securesms.conversation.ConversationActivity;
|
||||||
import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl;
|
import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl;
|
||||||
|
import org.thoughtcrime.securesms.logging.Log;
|
||||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||||
import org.thoughtcrime.securesms.util.BottomSheetUtil;
|
import org.thoughtcrime.securesms.util.BottomSheetUtil;
|
||||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||||
@ -31,6 +34,8 @@ import org.thoughtcrime.securesms.util.ThemeUtil;
|
|||||||
|
|
||||||
public final class GroupJoinBottomSheetDialogFragment extends BottomSheetDialogFragment {
|
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 static final String ARG_GROUP_INVITE_LINK_URL = "group_invite_url";
|
||||||
|
|
||||||
private ProgressBar busy;
|
private ProgressBar busy;
|
||||||
@ -93,14 +98,13 @@ public final class GroupJoinBottomSheetDialogFragment extends BottomSheetDialogF
|
|||||||
groupName.setText(details.getGroupName());
|
groupName.setText(details.getGroupName());
|
||||||
groupDetails.setText(requireContext().getResources().getQuantityString(R.plurals.GroupJoinBottomSheetDialogFragment_group_dot_d_members, details.getGroupMembershipCount(), details.getGroupMembershipCount()));
|
groupDetails.setText(requireContext().getResources().getQuantityString(R.plurals.GroupJoinBottomSheetDialogFragment_group_dot_d_members, details.getGroupMembershipCount(), details.getGroupMembershipCount()));
|
||||||
|
|
||||||
switch (FeatureFlags.clientLocalGroupJoinStatus()) {
|
switch (getGroupJoinStatus()) {
|
||||||
case COMING_SOON:
|
case COMING_SOON:
|
||||||
groupJoinExplain.setText(R.string.GroupJoinUpdateRequiredBottomSheetDialogFragment_coming_soon);
|
groupJoinExplain.setText(R.string.GroupJoinUpdateRequiredBottomSheetDialogFragment_coming_soon);
|
||||||
groupCancelButton.setText(android.R.string.ok);
|
groupCancelButton.setText(android.R.string.ok);
|
||||||
groupJoinButton.setVisibility(View.GONE);
|
groupJoinButton.setVisibility(View.GONE);
|
||||||
break;
|
break;
|
||||||
case UPDATE_TO_JOIN:
|
case UPDATE_TO_JOIN:
|
||||||
case LOCAL_CAN_JOIN:
|
|
||||||
groupJoinExplain.setText(R.string.GroupJoinUpdateRequiredBottomSheetDialogFragment_update_message);
|
groupJoinExplain.setText(R.string.GroupJoinUpdateRequiredBottomSheetDialogFragment_update_message);
|
||||||
groupJoinButton.setText(R.string.GroupJoinUpdateRequiredBottomSheetDialogFragment_update_signal);
|
groupJoinButton.setText(R.string.GroupJoinUpdateRequiredBottomSheetDialogFragment_update_signal);
|
||||||
groupJoinButton.setOnClickListener(v -> {
|
groupJoinButton.setOnClickListener(v -> {
|
||||||
@ -109,6 +113,17 @@ public final class GroupJoinBottomSheetDialogFragment extends BottomSheetDialogF
|
|||||||
});
|
});
|
||||||
groupJoinButton.setVisibility(View.VISIBLE);
|
groupJoinButton.setVisibility(View.VISIBLE);
|
||||||
break;
|
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);
|
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.isBusy().observe(getViewLifecycleOwner(), isBusy -> busy.setVisibility(isBusy ? View.VISIBLE : View.GONE));
|
||||||
|
|
||||||
viewModel.getErrors().observe(getViewLifecycleOwner(), error -> {
|
viewModel.getErrors().observe(getViewLifecycleOwner(), error -> {
|
||||||
Toast.makeText(requireContext(), errorToMessage(error), Toast.LENGTH_SHORT).show();
|
Toast.makeText(requireContext(), errorToMessage(error), Toast.LENGTH_SHORT).show();
|
||||||
dismiss();
|
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) {
|
if (error == FetchGroupDetailsError.GroupLinkNotActive) {
|
||||||
return getString(R.string.GroupJoinBottomSheetDialogFragment_this_group_link_is_not_active);
|
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);
|
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() {
|
private GroupInviteLinkUrl getGroupInviteLinkUrl() {
|
||||||
try {
|
try {
|
||||||
//noinspection ConstantConditions
|
//noinspection ConstantConditions
|
||||||
|
@ -5,15 +5,17 @@ import android.content.Context;
|
|||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import androidx.annotation.WorkerThread;
|
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.storageservice.protos.groups.local.DecryptedGroupJoinInfo;
|
||||||
import org.signal.zkgroup.VerificationFailedException;
|
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.GroupManager;
|
||||||
|
import org.thoughtcrime.securesms.groups.MembershipNotSuitableForV2Exception;
|
||||||
import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl;
|
import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl;
|
||||||
import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob;
|
import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob;
|
||||||
import org.thoughtcrime.securesms.logging.Log;
|
import org.thoughtcrime.securesms.logging.Log;
|
||||||
|
import org.thoughtcrime.securesms.util.AsynchronousCallback;
|
||||||
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
|
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
|
||||||
import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException;
|
import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException;
|
||||||
|
|
||||||
@ -31,7 +33,7 @@ final class GroupJoinRepository {
|
|||||||
this.groupInviteLinkUrl = groupInviteLinkUrl;
|
this.groupInviteLinkUrl = groupInviteLinkUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
void getGroupDetails(@NonNull GetGroupDetailsCallback callback) {
|
void getGroupDetails(@NonNull AsynchronousCallback.WorkerThread<GroupDetails, FetchGroupDetailsError> callback) {
|
||||||
SignalExecutors.UNBOUNDED.execute(() -> {
|
SignalExecutors.UNBOUNDED.execute(() -> {
|
||||||
try {
|
try {
|
||||||
callback.onComplete(getGroupDetails());
|
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
|
@WorkerThread
|
||||||
private @NonNull GroupDetails getGroupDetails()
|
private @NonNull GroupDetails getGroupDetails()
|
||||||
throws VerificationFailedException, IOException, GroupLinkNotActiveException
|
throws VerificationFailedException, IOException, GroupLinkNotActiveException
|
||||||
@ -51,14 +77,9 @@ final class GroupJoinRepository {
|
|||||||
groupInviteLinkUrl.getGroupMasterKey(),
|
groupInviteLinkUrl.getGroupMasterKey(),
|
||||||
groupInviteLinkUrl.getPassword());
|
groupInviteLinkUrl.getPassword());
|
||||||
|
|
||||||
byte[] avatarBytes = tryGetAvatarBytes(joinInfo);
|
byte[] avatarBytes = tryGetAvatarBytes(joinInfo);
|
||||||
boolean requiresAdminApproval = joinInfo.getAddFromInviteLink() == AccessControl.AccessRequired.ADMINISTRATOR;
|
|
||||||
|
|
||||||
return new GroupDetails(joinInfo.getTitle(),
|
return new GroupDetails(joinInfo, avatarBytes);
|
||||||
avatarBytes,
|
|
||||||
joinInfo.getMemberCount(),
|
|
||||||
requiresAdminApproval,
|
|
||||||
joinInfo.getRevision());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private @Nullable byte[] tryGetAvatarBytes(@NonNull DecryptedGroupJoinInfo joinInfo) {
|
private @Nullable byte[] tryGetAvatarBytes(@NonNull DecryptedGroupJoinInfo joinInfo) {
|
||||||
@ -69,9 +90,4 @@ final class GroupJoinRepository {
|
|||||||
return null;
|
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 android.content.Context;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
import androidx.lifecycle.LiveData;
|
import androidx.lifecycle.LiveData;
|
||||||
import androidx.lifecycle.MediatorLiveData;
|
import androidx.lifecycle.MediatorLiveData;
|
||||||
import androidx.lifecycle.MutableLiveData;
|
import androidx.lifecycle.MutableLiveData;
|
||||||
@ -10,35 +11,62 @@ import androidx.lifecycle.ViewModel;
|
|||||||
import androidx.lifecycle.ViewModelProvider;
|
import androidx.lifecycle.ViewModelProvider;
|
||||||
|
|
||||||
import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl;
|
import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl;
|
||||||
|
import org.thoughtcrime.securesms.util.AsynchronousCallback;
|
||||||
import org.thoughtcrime.securesms.util.SingleLiveEvent;
|
import org.thoughtcrime.securesms.util.SingleLiveEvent;
|
||||||
|
|
||||||
public class GroupJoinViewModel extends ViewModel {
|
public class GroupJoinViewModel extends ViewModel {
|
||||||
|
|
||||||
|
private final GroupJoinRepository repository;
|
||||||
private final MutableLiveData<GroupDetails> groupDetails = new MutableLiveData<>();
|
private final MutableLiveData<GroupDetails> groupDetails = new MutableLiveData<>();
|
||||||
private final MutableLiveData<FetchGroupDetailsError> errors = new SingleLiveEvent<>();
|
private final MutableLiveData<FetchGroupDetailsError> errors = new SingleLiveEvent<>();
|
||||||
|
private final MutableLiveData<JoinGroupError> joinErrors = new SingleLiveEvent<>();
|
||||||
private final MutableLiveData<Boolean> busy = new MediatorLiveData<>();
|
private final MutableLiveData<Boolean> busy = new MediatorLiveData<>();
|
||||||
|
private final MutableLiveData<JoinGroupSuccess> joinSuccess = new SingleLiveEvent<>();
|
||||||
|
|
||||||
private GroupJoinViewModel(@NonNull GroupJoinRepository repository) {
|
private GroupJoinViewModel(@NonNull GroupJoinRepository repository) {
|
||||||
|
this.repository = repository;
|
||||||
|
|
||||||
busy.setValue(true);
|
busy.setValue(true);
|
||||||
repository.getGroupDetails(new GroupJoinRepository.GetGroupDetailsCallback() {
|
repository.getGroupDetails(new AsynchronousCallback.WorkerThread<GroupDetails, FetchGroupDetailsError>() {
|
||||||
@Override
|
@Override
|
||||||
public void onComplete(@NonNull GroupDetails details) {
|
public void onComplete(@Nullable GroupDetails details) {
|
||||||
busy.postValue(false);
|
busy.postValue(false);
|
||||||
groupDetails.postValue(details);
|
groupDetails.postValue(details);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onError(@NonNull FetchGroupDetailsError error) {
|
public void onError(@Nullable FetchGroupDetailsError error) {
|
||||||
busy.postValue(false);
|
busy.postValue(false);
|
||||||
errors.postValue(error);
|
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() {
|
LiveData<GroupDetails> getGroupDetails() {
|
||||||
return groupDetails;
|
return groupDetails;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LiveData<JoinGroupSuccess> getJoinSuccess() {
|
||||||
|
return joinSuccess;
|
||||||
|
}
|
||||||
|
|
||||||
LiveData<Boolean> isBusy() {
|
LiveData<Boolean> isBusy() {
|
||||||
return busy;
|
return busy;
|
||||||
}
|
}
|
||||||
@ -47,6 +75,10 @@ public class GroupJoinViewModel extends ViewModel {
|
|||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LiveData<JoinGroupError> getJoinErrors() {
|
||||||
|
return joinErrors;
|
||||||
|
}
|
||||||
|
|
||||||
public static class Factory implements ViewModelProvider.Factory {
|
public static class Factory implements ViewModelProvider.Factory {
|
||||||
|
|
||||||
private final Context context;
|
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;
|
package org.thoughtcrime.securesms.groups.v2.processing;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
|
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
|
||||||
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
|
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
|
||||||
import org.thoughtcrime.securesms.logging.Log;
|
import org.thoughtcrime.securesms.logging.Log;
|
||||||
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil;
|
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil;
|
||||||
import org.whispersystems.signalservice.api.groupsv2.GroupChangeReconstruct;
|
import org.whispersystems.signalservice.api.groupsv2.GroupChangeReconstruct;
|
||||||
|
import org.whispersystems.signalservice.api.groupsv2.GroupChangeUtil;
|
||||||
import org.whispersystems.signalservice.api.groupsv2.NotAbleToApplyGroupV2ChangeException;
|
import org.whispersystems.signalservice.api.groupsv2.NotAbleToApplyGroupV2ChangeException;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Objects;
|
import java.util.List;
|
||||||
|
|
||||||
final class GroupStateMapper {
|
final class GroupStateMapper {
|
||||||
|
|
||||||
private static final String TAG = Log.tag(GroupStateMapper.class);
|
private static final String TAG = Log.tag(GroupStateMapper.class);
|
||||||
|
|
||||||
static final int LATEST = Integer.MAX_VALUE;
|
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());
|
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,
|
static @NonNull AdvanceGroupStateResult partiallyAdvanceGroupState(@NonNull GlobalGroupState inputState,
|
||||||
int maximumRevisionToApply)
|
int maximumRevisionToApply)
|
||||||
{
|
{
|
||||||
ArrayList<LocalGroupLogEntry> appliedChanges = new ArrayList<>(inputState.getServerHistory().size());
|
AdvanceGroupStateResult groupStateResult = processChanges(inputState, maximumRevisionToApply);
|
||||||
HashMap<Integer, ServerGroupLogEntry> statesToApplyNow = new HashMap<>(inputState.getServerHistory().size());
|
|
||||||
ArrayList<ServerGroupLogEntry> statesToApplyLater = new ArrayList<>(inputState.getServerHistory().size());
|
return cleanDuplicatedChanges(groupStateResult, inputState.getLocalState());
|
||||||
DecryptedGroup current = 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()) {
|
if (inputState.getServerHistory().isEmpty()) {
|
||||||
return new AdvanceGroupStateResult(Collections.emptyList(), new GlobalGroupState(current, Collections.emptyList()));
|
return new AdvanceGroupStateResult(Collections.emptyList(), new GlobalGroupState(current, Collections.emptyList()));
|
||||||
@ -55,9 +66,15 @@ final class GroupStateMapper {
|
|||||||
|
|
||||||
Collections.sort(statesToApplyLater, BY_REVISION);
|
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);
|
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++) {
|
for (int revision = from; revision >= 0 && revision <= to; revision++) {
|
||||||
ServerGroupLogEntry entry = statesToApplyNow.get(revision);
|
ServerGroupLogEntry entry = statesToApplyNow.get(revision);
|
||||||
if (entry == null) {
|
if (entry == null) {
|
||||||
@ -65,59 +82,64 @@ final class GroupStateMapper {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
DecryptedGroup groupAtRevision = entry.getGroup();
|
if (stateChain.getLatestState() == null && entry.getGroup() != null && current != null && current.getRevision() == PLACEHOLDER_REVISION) {
|
||||||
DecryptedGroupChange changeAtRevision = entry.getChange();
|
DecryptedGroup previousState = DecryptedGroup.newBuilder(entry.getGroup())
|
||||||
|
.setTitle(current.getTitle())
|
||||||
|
.setAvatar(current.getAvatar())
|
||||||
|
.build();
|
||||||
|
|
||||||
if (current == null) {
|
stateChain.push(previousState, 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (current.getRevision() + 1 != revision) {
|
stateChain.push(entry.getGroup(), entry.getChange());
|
||||||
Log.w(TAG, "Detected gap V" + revision);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (changeAtRevision == null) {
|
|
||||||
Log.w(TAG, "Reconstructing change for V" + revision);
|
|
||||||
changeAtRevision = GroupChangeReconstruct.reconstructGroupChange(current, Objects.requireNonNull(groupAtRevision));
|
|
||||||
}
|
|
||||||
|
|
||||||
DecryptedGroup groupWithChangeApplied;
|
|
||||||
try {
|
|
||||||
groupWithChangeApplied = DecryptedGroupUtil.applyWithoutRevisionCheck(current, changeAtRevision);
|
|
||||||
} catch (NotAbleToApplyGroupV2ChangeException e) {
|
|
||||||
Log.w(TAG, "Unable to apply V" + revision, e);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
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));
|
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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
return DecryptedGroupUtil.applyWithoutRevisionCheck(group, change);
|
||||||
|
} catch (NotAbleToApplyGroupV2ChangeException e) {
|
||||||
|
Log.w(TAG, "Unable to apply V" + change.getRevision(), e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(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.DatabaseFactory;
|
||||||
import org.thoughtcrime.securesms.database.GroupDatabase;
|
import org.thoughtcrime.securesms.database.GroupDatabase;
|
||||||
import org.thoughtcrime.securesms.database.MessageDatabase;
|
import org.thoughtcrime.securesms.database.MessageDatabase;
|
||||||
import org.thoughtcrime.securesms.database.MmsDatabase;
|
|
||||||
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
||||||
import org.thoughtcrime.securesms.database.SmsDatabase;
|
|
||||||
import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context;
|
import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context;
|
||||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||||
import org.thoughtcrime.securesms.groups.GroupId;
|
import org.thoughtcrime.securesms.groups.GroupId;
|
||||||
@ -65,7 +63,8 @@ public final class GroupsV2StateProcessor {
|
|||||||
|
|
||||||
private static final String TAG = Log.tag(GroupsV2StateProcessor.class);
|
private static final String TAG = Log.tag(GroupsV2StateProcessor.class);
|
||||||
|
|
||||||
public static final int LATEST = GroupStateMapper.LATEST;
|
public static final int LATEST = GroupStateMapper.LATEST;
|
||||||
|
public static final int PLACEHOLDER_REVISION = GroupStateMapper.PLACEHOLDER_REVISION;
|
||||||
|
|
||||||
private final Context context;
|
private final Context context;
|
||||||
private final JobManager jobManager;
|
private final JobManager jobManager;
|
||||||
@ -177,9 +176,26 @@ public final class GroupsV2StateProcessor {
|
|||||||
try {
|
try {
|
||||||
inputGroupState = queryServer(localState, revision == LATEST && localState == null);
|
inputGroupState = queryServer(localState, revision == LATEST && localState == null);
|
||||||
} catch (GroupNotAMemberException e) {
|
} catch (GroupNotAMemberException e) {
|
||||||
Log.w(TAG, "Unable to query server for group " + groupId + " server says we're not in group, inserting leave message");
|
if (localState != null && signedGroupChange != null) {
|
||||||
insertGroupLeave();
|
try {
|
||||||
throw e;
|
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 {
|
} else {
|
||||||
Log.i(TAG, "Saved server query for group change");
|
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.SecurityEvent;
|
||||||
import org.thoughtcrime.securesms.crypto.storage.TextSecureSessionStore;
|
import org.thoughtcrime.securesms.crypto.storage.TextSecureSessionStore;
|
||||||
import org.thoughtcrime.securesms.database.AttachmentDatabase;
|
import org.thoughtcrime.securesms.database.AttachmentDatabase;
|
||||||
import org.thoughtcrime.securesms.database.Database;
|
|
||||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||||
import org.thoughtcrime.securesms.database.GroupDatabase;
|
import org.thoughtcrime.securesms.database.GroupDatabase;
|
||||||
import org.thoughtcrime.securesms.database.GroupReceiptDatabase;
|
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;
|
||||||
import org.thoughtcrime.securesms.database.MessageDatabase.InsertResult;
|
import org.thoughtcrime.securesms.database.MessageDatabase.InsertResult;
|
||||||
import org.thoughtcrime.securesms.database.MessageDatabase.SyncMessageId;
|
import org.thoughtcrime.securesms.database.MessageDatabase.SyncMessageId;
|
||||||
import org.thoughtcrime.securesms.database.MmsDatabase;
|
|
||||||
import org.thoughtcrime.securesms.database.MmsSmsDatabase;
|
import org.thoughtcrime.securesms.database.MmsSmsDatabase;
|
||||||
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
||||||
import org.thoughtcrime.securesms.database.SmsDatabase;
|
|
||||||
import org.thoughtcrime.securesms.database.StickerDatabase;
|
import org.thoughtcrime.securesms.database.StickerDatabase;
|
||||||
import org.thoughtcrime.securesms.database.ThreadDatabase;
|
import org.thoughtcrime.securesms.database.ThreadDatabase;
|
||||||
import org.thoughtcrime.securesms.database.model.Mention;
|
import org.thoughtcrime.securesms.database.model.Mention;
|
||||||
@ -350,16 +347,16 @@ public final class PushProcessMessageJob extends BaseJob {
|
|||||||
|
|
||||||
if (isGv2Message) {
|
if (isGv2Message) {
|
||||||
GroupMasterKey groupMasterKey = message.getGroupContext().get().getGroupV2().get().getMasterKey();
|
GroupMasterKey groupMasterKey = message.getGroupContext().get().getGroupV2().get().getMasterKey();
|
||||||
|
GroupId.V2 groupIdV2 = groupId.get().requireV2();
|
||||||
|
|
||||||
if (!groupV2PreProcessMessage(content, groupMasterKey, message.getGroupContext().get().getGroupV2().get())) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
GroupId.V2 groupIdV2 = groupId.get().requireV2();
|
Recipient sender = Recipient.externalPush(context, content.getSender());
|
||||||
Recipient sender = Recipient.externalPush(context, content.getSender());
|
|
||||||
if (!groupDatabase.isCurrentMember(groupIdV2, sender.getId())) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -7,7 +7,6 @@ import org.signal.zkgroup.groups.GroupMasterKey;
|
|||||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||||
import org.thoughtcrime.securesms.database.GroupDatabase;
|
import org.thoughtcrime.securesms.database.GroupDatabase;
|
||||||
import org.thoughtcrime.securesms.database.MessageDatabase;
|
import org.thoughtcrime.securesms.database.MessageDatabase;
|
||||||
import org.thoughtcrime.securesms.database.MmsDatabase;
|
|
||||||
import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context;
|
import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context;
|
||||||
import org.thoughtcrime.securesms.groups.GroupChangeBusyException;
|
import org.thoughtcrime.securesms.groups.GroupChangeBusyException;
|
||||||
import org.thoughtcrime.securesms.groups.GroupId;
|
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.MmsException;
|
||||||
import org.thoughtcrime.securesms.mms.OutgoingGroupUpdateMessage;
|
import org.thoughtcrime.securesms.mms.OutgoingGroupUpdateMessage;
|
||||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
|
||||||
import org.thoughtcrime.securesms.util.Hex;
|
import org.thoughtcrime.securesms.util.Hex;
|
||||||
import org.whispersystems.libsignal.util.guava.Optional;
|
import org.whispersystems.libsignal.util.guava.Optional;
|
||||||
import org.whispersystems.signalservice.api.groupsv2.NoCredentialForRedemptionTimeException;
|
import org.whispersystems.signalservice.api.groupsv2.NoCredentialForRedemptionTimeException;
|
||||||
@ -79,11 +77,12 @@ public final class WakeGroupV2Job extends BaseJob {
|
|||||||
GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context);
|
GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context);
|
||||||
GroupId.V2 groupId = GroupId.v2(groupMasterKey);
|
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);
|
GroupManager.updateGroupFromServer(context, groupMasterKey, GroupsV2StateProcessor.LATEST, System.currentTimeMillis(), null);
|
||||||
Log.i(TAG, "Group created " + groupId);
|
Log.i(TAG, "Group created " + groupId);
|
||||||
} else {
|
|
||||||
Log.w(TAG, "Group already exists " + groupId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Optional<GroupDatabase.GroupRecord> group = groupDatabase.getGroup(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
|
@Override
|
||||||
protected void onPostExecute(Long threadId) {
|
protected void onPostExecute(Long threadId) {
|
||||||
Intent intent = new Intent(context, ConversationActivity.class);
|
Intent intent = ConversationActivity.buildIntent(context, recipient.getId(), threadId);
|
||||||
intent.putExtra(ConversationActivity.RECIPIENT_EXTRA, recipient.getId());
|
|
||||||
intent.putExtra(ConversationActivity.THREAD_ID_EXTRA, threadId);
|
|
||||||
|
|
||||||
if (!TextUtils.isEmpty(text)) {
|
if (!TextUtils.isEmpty(text)) {
|
||||||
intent.putExtra(ConversationActivity.TEXT_EXTRA, text);
|
intent.putExtra(ConversationActivity.TEXT_EXTRA, text);
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
package org.thoughtcrime.securesms.util;
|
package org.thoughtcrime.securesms.util;
|
||||||
|
|
||||||
import android.os.Handler;
|
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
|
* 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.
|
* {@code threshold} milliseconds.
|
||||||
*/
|
*/
|
||||||
public Debouncer(long threshold) {
|
public Debouncer(long threshold) {
|
||||||
this.handler = new Handler();
|
this.handler = new Handler(Looper.getMainLooper());
|
||||||
this.threshold = threshold;
|
this.threshold = threshold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -99,6 +99,10 @@
|
|||||||
layout="@layout/conversation_no_longer_a_member"
|
layout="@layout/conversation_no_longer_a_member"
|
||||||
android:visibility="gone" />
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<include
|
||||||
|
layout="@layout/conversation_requesting_bottom_banner"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
<include layout="@layout/conversation_input_panel" />
|
<include layout="@layout/conversation_input_panel" />
|
||||||
|
|
||||||
<include layout="@layout/conversation_search_nav" />
|
<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_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_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_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_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>
|
<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_you_are_already_a_member">You are already a member</string>
|
||||||
<string name="GroupJoinBottomSheetDialogFragment_join">Join</string>
|
<string name="GroupJoinBottomSheetDialogFragment_join">Join</string>
|
||||||
<string name="GroupJoinBottomSheetDialogFragment_request_to_join">Request to 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_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_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_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>
|
<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 -->
|
<!-- 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_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_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_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>
|
<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_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_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_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 -->
|
<!-- 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.")));
|
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
|
@Test
|
||||||
public void unknown_approved_your_join_request() {
|
public void unknown_approved_your_join_request() {
|
||||||
DecryptedGroupChange change = changeByUnknown()
|
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.")));
|
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
|
@Test
|
||||||
public void unknown_denied_your_join_request() {
|
public void unknown_denied_your_join_request() {
|
||||||
DecryptedGroupChange change = changeByUnknown()
|
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() {
|
public void known_group_three_states_to_update_update_latest_handle_gap_with_changes() {
|
||||||
DecryptedGroup currentState = state(0);
|
DecryptedGroup currentState = state(0);
|
||||||
ServerGroupLogEntry log1 = serverLogEntry(1);
|
ServerGroupLogEntry log1 = serverLogEntry(1);
|
||||||
|
DecryptedGroup state3a = DecryptedGroup.newBuilder()
|
||||||
|
.setRevision(3)
|
||||||
|
.setTitle("Group Revision " + 3)
|
||||||
|
.build();
|
||||||
DecryptedGroup state3 = DecryptedGroup.newBuilder()
|
DecryptedGroup state3 = DecryptedGroup.newBuilder()
|
||||||
.setRevision(3)
|
.setRevision(3)
|
||||||
.setTitle("Group Revision " + 3)
|
.setTitle("Group Revision " + 3)
|
||||||
@ -213,11 +217,11 @@ public final class GroupStateMapperTest {
|
|||||||
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, asList(log1, log3, log4)), LATEST);
|
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, asList(log1, log3, log4)), LATEST);
|
||||||
|
|
||||||
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(asList(asLocal(log1),
|
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(asList(asLocal(log1),
|
||||||
asLocal(log3),
|
new LocalGroupLogEntry(state3a, log3.getChange()),
|
||||||
new LocalGroupLogEntry(state3, DecryptedGroupChange.newBuilder()
|
new LocalGroupLogEntry(state3, DecryptedGroupChange.newBuilder()
|
||||||
.setRevision(3)
|
.setRevision(3)
|
||||||
.setNewAvatar(DecryptedString.newBuilder().setValue("Lost Avatar Update"))
|
.setNewAvatar(DecryptedString.newBuilder().setValue("Lost Avatar Update"))
|
||||||
.build()),
|
.build()),
|
||||||
asLocal(log4))));
|
asLocal(log4))));
|
||||||
|
|
||||||
assertNewState(new GlobalGroupState(log4.getGroup(), emptyList()), advanceGroupStateResult.getNewGlobalGroupState());
|
assertNewState(new GlobalGroupState(log4.getGroup(), emptyList()), advanceGroupStateResult.getNewGlobalGroupState());
|
||||||
@ -259,11 +263,16 @@ public final class GroupStateMapperTest {
|
|||||||
DecryptedMember newMember = DecryptedMember.newBuilder()
|
DecryptedMember newMember = DecryptedMember.newBuilder()
|
||||||
.setUuid(UuidUtil.toByteString(UUID.randomUUID()))
|
.setUuid(UuidUtil.toByteString(UUID.randomUUID()))
|
||||||
.build();
|
.build();
|
||||||
ServerGroupLogEntry log8 = new ServerGroupLogEntry(DecryptedGroup.newBuilder()
|
DecryptedGroup state7b = DecryptedGroup.newBuilder()
|
||||||
.setRevision(8)
|
.setRevision(8)
|
||||||
.addMembers(newMember)
|
.setTitle("Group Revision " + 8)
|
||||||
.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) );
|
change(8) );
|
||||||
ServerGroupLogEntry log9 = new ServerGroupLogEntry(DecryptedGroup.newBuilder()
|
ServerGroupLogEntry log9 = new ServerGroupLogEntry(DecryptedGroup.newBuilder()
|
||||||
.setRevision(9)
|
.setRevision(9)
|
||||||
@ -275,11 +284,11 @@ public final class GroupStateMapperTest {
|
|||||||
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, asList(log7, log8, log9)), LATEST);
|
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, asList(log7, log8, log9)), LATEST);
|
||||||
|
|
||||||
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(asList(asLocal(log7),
|
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(asList(asLocal(log7),
|
||||||
asLocal(log8),
|
new LocalGroupLogEntry(state7b, log8.getChange()),
|
||||||
asLocal(new ServerGroupLogEntry(log8.getGroup(), DecryptedGroupChange.newBuilder()
|
new LocalGroupLogEntry(state8, DecryptedGroupChange.newBuilder()
|
||||||
.setRevision(8)
|
.setRevision(8)
|
||||||
.addNewMembers(newMember)
|
.addNewMembers(newMember)
|
||||||
.build())),
|
.build()),
|
||||||
asLocal(log9))));
|
asLocal(log9))));
|
||||||
assertNewState(new GlobalGroupState(log9.getGroup(), emptyList()), advanceGroupStateResult.getNewGlobalGroupState());
|
assertNewState(new GlobalGroupState(log9.getGroup(), emptyList()), advanceGroupStateResult.getNewGlobalGroupState());
|
||||||
assertEquals(log9.getGroup(), advanceGroupStateResult.getNewGlobalGroupState().getLocalState());
|
assertEquals(log9.getGroup(), advanceGroupStateResult.getNewGlobalGroupState().getLocalState());
|
||||||
@ -298,18 +307,136 @@ public final class GroupStateMapperTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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()
|
DecryptedGroup currentState = DecryptedGroup.newBuilder()
|
||||||
.setRevision(6)
|
.setRevision(GroupStateMapper.PLACEHOLDER_REVISION)
|
||||||
.setTitle("Incorrect group title, Revision " + 6)
|
.setTitle("Incorrect group title, Revision " + 6)
|
||||||
.build();
|
.build();
|
||||||
ServerGroupLogEntry log6 = serverLogEntryWholeStateOnly(6);
|
ServerGroupLogEntry log6 = serverLogEntry(6);
|
||||||
|
|
||||||
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, singletonList(log6)), LATEST);
|
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, singletonList(log6)), LATEST);
|
||||||
|
|
||||||
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(singletonList(localLogEntryNoEditor(6))));
|
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(singletonList(asLocal(log6))));
|
||||||
assertNewState(new GlobalGroupState(state(6), emptyList()), advanceGroupStateResult.getNewGlobalGroupState());
|
assertTrue(advanceGroupStateResult.getNewGlobalGroupState().getServerHistory().isEmpty());
|
||||||
assertEquals(state(6), advanceGroupStateResult.getNewGlobalGroupState().getLocalState());
|
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) {
|
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;
|
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.
|
* Removes the uuid from the full members of a group.
|
||||||
* <p>
|
* <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);
|
||||||
|
}
|
||||||
|
}
|
@ -64,7 +64,43 @@ public final class GroupChangeUtil {
|
|||||||
DecryptedGroupChange conflictingChange,
|
DecryptedGroupChange conflictingChange,
|
||||||
GroupChange.Actions encryptedChange)
|
GroupChange.Actions encryptedChange)
|
||||||
{
|
{
|
||||||
GroupChange.Actions.Builder result = GroupChange.Actions.newBuilder(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, DecryptedMember> fullMembersByUuid = new HashMap<>(groupState.getMembersCount());
|
||||||
HashMap<ByteString, DecryptedPendingMember> pendingMembersByUuid = new HashMap<>(groupState.getPendingMembersCount());
|
HashMap<ByteString, DecryptedPendingMember> pendingMembersByUuid = new HashMap<>(groupState.getPendingMembersCount());
|
||||||
HashMap<ByteString, DecryptedRequestingMember> requestingMembersByUuid = new HashMap<>(groupState.getMembersCount());
|
HashMap<ByteString, DecryptedRequestingMember> requestingMembersByUuid = new HashMap<>(groupState.getMembersCount());
|
||||||
@ -81,27 +117,25 @@ public final class GroupChangeUtil {
|
|||||||
requestingMembersByUuid.put(member.getUuid(), member);
|
requestingMembersByUuid.put(member.getUuid(), member);
|
||||||
}
|
}
|
||||||
|
|
||||||
resolveField3AddMembers (conflictingChange, result, fullMembersByUuid, pendingMembersByUuid);
|
resolveField3AddMembers (conflictingChange, changeSetModifier, fullMembersByUuid, pendingMembersByUuid);
|
||||||
resolveField4DeleteMembers (conflictingChange, result, fullMembersByUuid);
|
resolveField4DeleteMembers (conflictingChange, changeSetModifier, fullMembersByUuid);
|
||||||
resolveField5ModifyMemberRoles (conflictingChange, result, fullMembersByUuid);
|
resolveField5ModifyMemberRoles (conflictingChange, changeSetModifier, fullMembersByUuid);
|
||||||
resolveField6ModifyProfileKeys (conflictingChange, result, fullMembersByUuid);
|
resolveField6ModifyProfileKeys (conflictingChange, changeSetModifier, fullMembersByUuid);
|
||||||
resolveField7AddPendingMembers (conflictingChange, result, fullMembersByUuid, pendingMembersByUuid);
|
resolveField7AddPendingMembers (conflictingChange, changeSetModifier, fullMembersByUuid, pendingMembersByUuid);
|
||||||
resolveField8DeletePendingMembers (conflictingChange, result, pendingMembersByUuid);
|
resolveField8DeletePendingMembers (conflictingChange, changeSetModifier, pendingMembersByUuid);
|
||||||
resolveField9PromotePendingMembers (conflictingChange, result, pendingMembersByUuid);
|
resolveField9PromotePendingMembers (conflictingChange, changeSetModifier, pendingMembersByUuid);
|
||||||
resolveField10ModifyTitle (groupState, conflictingChange, result);
|
resolveField10ModifyTitle (groupState, conflictingChange, changeSetModifier);
|
||||||
resolveField11ModifyAvatar (groupState, conflictingChange, result);
|
resolveField11ModifyAvatar (groupState, conflictingChange, changeSetModifier);
|
||||||
resolveField12modifyDisappearingMessagesTimer(groupState, conflictingChange, result);
|
resolveField12modifyDisappearingMessagesTimer(groupState, conflictingChange, changeSetModifier);
|
||||||
resolveField13modifyAttributesAccess (groupState, conflictingChange, result);
|
resolveField13modifyAttributesAccess (groupState, conflictingChange, changeSetModifier);
|
||||||
resolveField14modifyAttributesAccess (groupState, conflictingChange, result);
|
resolveField14modifyAttributesAccess (groupState, conflictingChange, changeSetModifier);
|
||||||
resolveField15modifyAddFromInviteLinkAccess (groupState, conflictingChange, result);
|
resolveField15modifyAddFromInviteLinkAccess (groupState, conflictingChange, changeSetModifier);
|
||||||
resolveField16AddRequestingMembers (conflictingChange, result, fullMembersByUuid, pendingMembersByUuid);
|
resolveField16AddRequestingMembers (conflictingChange, changeSetModifier, fullMembersByUuid, pendingMembersByUuid);
|
||||||
resolveField17DeleteMembers (conflictingChange, result, requestingMembersByUuid);
|
resolveField17DeleteMembers (conflictingChange, changeSetModifier, requestingMembersByUuid);
|
||||||
resolveField18PromoteRequestingMembers (conflictingChange, result, requestingMembersByUuid);
|
resolveField18PromoteRequestingMembers (conflictingChange, changeSetModifier, requestingMembersByUuid);
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
List<DecryptedMember> newMembersList = conflictingChange.getNewMembersList();
|
||||||
|
|
||||||
for (int i = newMembersList.size() - 1; i >= 0; i--) {
|
for (int i = newMembersList.size() - 1; i >= 0; i--) {
|
||||||
@ -110,14 +144,12 @@ public final class GroupChangeUtil {
|
|||||||
if (fullMembersByUuid.containsKey(member.getUuid())) {
|
if (fullMembersByUuid.containsKey(member.getUuid())) {
|
||||||
result.removeAddMembers(i);
|
result.removeAddMembers(i);
|
||||||
} else if (pendingMembersByUuid.containsKey(member.getUuid())) {
|
} else if (pendingMembersByUuid.containsKey(member.getUuid())) {
|
||||||
GroupChange.Actions.AddMemberAction addMemberAction = result.getAddMembersList().get(i);
|
result.moveAddToPromote(i);
|
||||||
result.removeAddMembers(i);
|
|
||||||
result.addPromotePendingMembers(GroupChange.Actions.PromotePendingMemberAction.newBuilder().setPresentation(addMemberAction.getAdded().getPresentation()));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
List<ByteString> deletedMembersList = conflictingChange.getDeleteMembersList();
|
||||||
|
|
||||||
for (int i = deletedMembersList.size() - 1; i >= 0; i--) {
|
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();
|
List<DecryptedModifyMemberRole> modifyRolesList = conflictingChange.getModifyMemberRolesList();
|
||||||
|
|
||||||
for (int i = modifyRolesList.size() - 1; i >= 0; i--) {
|
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();
|
List<DecryptedMember> modifyProfileKeysList = conflictingChange.getModifiedProfileKeysList();
|
||||||
|
|
||||||
for (int i = modifyProfileKeysList.size() - 1; i >= 0; i--) {
|
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();
|
List<DecryptedPendingMember> newPendingMembersList = conflictingChange.getNewPendingMembersList();
|
||||||
|
|
||||||
for (int i = newPendingMembersList.size() - 1; i >= 0; i--) {
|
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();
|
List<DecryptedPendingMemberRemoval> deletePendingMembersList = conflictingChange.getDeletePendingMembersList();
|
||||||
|
|
||||||
for (int i = deletePendingMembersList.size() - 1; i >= 0; i--) {
|
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();
|
List<DecryptedMember> promotePendingMembersList = conflictingChange.getPromotePendingMembersList();
|
||||||
|
|
||||||
for (int i = promotePendingMembersList.size() - 1; i >= 0; i--) {
|
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())) {
|
if (conflictingChange.hasNewTitle() && conflictingChange.getNewTitle().getValue().equals(groupState.getTitle())) {
|
||||||
result.clearModifyTitle();
|
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())) {
|
if (conflictingChange.hasNewAvatar() && conflictingChange.getNewAvatar().getValue().equals(groupState.getAvatar())) {
|
||||||
result.clearModifyAvatar();
|
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()) {
|
if (conflictingChange.hasNewTimer() && conflictingChange.getNewTimer().getDuration() == groupState.getDisappearingMessagesTimer().getDuration()) {
|
||||||
result.clearModifyDisappearingMessagesTimer();
|
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()) {
|
if (conflictingChange.getNewAttributeAccess() == groupState.getAccessControl().getAttributes()) {
|
||||||
result.clearModifyAttributesAccess();
|
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()) {
|
if (conflictingChange.getNewMemberAccess() == groupState.getAccessControl().getMembers()) {
|
||||||
result.clearModifyMemberAccess();
|
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()) {
|
if (conflictingChange.getNewInviteLinkAccess() == groupState.getAccessControl().getAddFromInviteLink()) {
|
||||||
result.clearModifyAddFromInviteLinkAccess();
|
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();
|
List<DecryptedRequestingMember> newMembersList = conflictingChange.getNewRequestingMembersList();
|
||||||
|
|
||||||
for (int i = newMembersList.size() - 1; i >= 0; i--) {
|
for (int i = newMembersList.size() - 1; i >= 0; i--) {
|
||||||
@ -236,15 +268,13 @@ public final class GroupChangeUtil {
|
|||||||
if (fullMembersByUuid.containsKey(member.getUuid())) {
|
if (fullMembersByUuid.containsKey(member.getUuid())) {
|
||||||
result.removeAddRequestingMembers(i);
|
result.removeAddRequestingMembers(i);
|
||||||
} else if (pendingMembersByUuid.containsKey(member.getUuid())) {
|
} else if (pendingMembersByUuid.containsKey(member.getUuid())) {
|
||||||
GroupChange.Actions.AddRequestingMemberAction addMemberAction = result.getAddRequestingMembersList().get(i);
|
result.moveAddRequestingMembersToPromote(i);
|
||||||
result.removeAddRequestingMembers(i);
|
|
||||||
result.addPromotePendingMembers(0, GroupChange.Actions.PromotePendingMemberAction.newBuilder().setPresentation(addMemberAction.getAdded().getPresentation()));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void resolveField17DeleteMembers(DecryptedGroupChange conflictingChange,
|
private static void resolveField17DeleteMembers(DecryptedGroupChange conflictingChange,
|
||||||
GroupChange.Actions.Builder result,
|
ChangeSetModifier result,
|
||||||
HashMap<ByteString, DecryptedRequestingMember> requestingMembers)
|
HashMap<ByteString, DecryptedRequestingMember> requestingMembers)
|
||||||
{
|
{
|
||||||
List<ByteString> deletedMembersList = conflictingChange.getDeleteRequestingMembersList();
|
List<ByteString> deletedMembersList = conflictingChange.getDeleteRequestingMembersList();
|
||||||
@ -259,7 +289,7 @@ public final class GroupChangeUtil {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static void resolveField18PromoteRequestingMembers(DecryptedGroupChange conflictingChange,
|
private static void resolveField18PromoteRequestingMembers(DecryptedGroupChange conflictingChange,
|
||||||
GroupChange.Actions.Builder result,
|
ChangeSetModifier result,
|
||||||
HashMap<ByteString, DecryptedRequestingMember> requestingMembersByUuid)
|
HashMap<ByteString, DecryptedRequestingMember> requestingMembersByUuid)
|
||||||
{
|
{
|
||||||
List<DecryptedApproveMember> promoteRequestingMembersList = conflictingChange.getPromoteRequestingMembersList();
|
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
|
* - the master key does not match a group on the server
|
||||||
*/
|
*/
|
||||||
public final class GroupLinkNotActiveException extends Exception {
|
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,
|
public DecryptedGroupJoinInfo getGroupJoinInfo(GroupSecretParams groupSecretParams,
|
||||||
byte[] password,
|
Optional<byte[]> password,
|
||||||
GroupsV2AuthorizationString authorization)
|
GroupsV2AuthorizationString authorization)
|
||||||
throws IOException, GroupLinkNotActiveException
|
throws IOException, GroupLinkNotActiveException
|
||||||
{
|
{
|
||||||
@ -148,10 +148,11 @@ public final class GroupsV2Api {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public GroupChange patchGroup(GroupChange.Actions groupChange,
|
public GroupChange patchGroup(GroupChange.Actions groupChange,
|
||||||
GroupsV2AuthorizationString authorization)
|
GroupsV2AuthorizationString authorization,
|
||||||
|
Optional<byte[]> groupLinkPassword)
|
||||||
throws IOException
|
throws IOException
|
||||||
{
|
{
|
||||||
return socket.patchGroupsV2Group(groupChange, authorization.toString());
|
return socket.patchGroupsV2Group(groupChange, authorization.toString(), groupLinkPassword);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static HashMap<Integer, AuthCredentialResponse> parseCredentialResponse(CredentialResponse credentialResponse)
|
private static HashMap<Integer, AuthCredentialResponse> parseCredentialResponse(CredentialResponse credentialResponse)
|
||||||
|
@ -188,8 +188,7 @@ public final class GroupsV2Operations {
|
|||||||
|
|
||||||
actions.addAddMembers(GroupChange.Actions.AddMemberAction
|
actions.addAddMembers(GroupChange.Actions.AddMemberAction
|
||||||
.newBuilder()
|
.newBuilder()
|
||||||
.setAdded(groupOperations.member(profileKeyCredential, Member.Role.DEFAULT))
|
.setAdded(groupOperations.member(profileKeyCredential, Member.Role.DEFAULT)));
|
||||||
.setJoinFromInviteLink(true));
|
|
||||||
|
|
||||||
return actions;
|
return actions;
|
||||||
}
|
}
|
||||||
@ -574,6 +573,7 @@ public final class GroupsV2Operations {
|
|||||||
.setMemberCount(joinInfo.getMemberCount())
|
.setMemberCount(joinInfo.getMemberCount())
|
||||||
.setAddFromInviteLink(joinInfo.getAddFromInviteLink())
|
.setAddFromInviteLink(joinInfo.getAddFromInviteLink())
|
||||||
.setRevision(joinInfo.getRevision())
|
.setRevision(joinInfo.getRevision())
|
||||||
|
.setPendingAdminApproval(joinInfo.getPendingAdminApproval())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1957,11 +1957,14 @@ public class PushServiceSocket {
|
|||||||
return AvatarUploadAttributes.parseFrom(readBodyBytes(response));
|
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
|
throws NonSuccessfulResponseCodeException, PushNetworkException, InvalidProtocolBufferException
|
||||||
{
|
{
|
||||||
|
String path = groupLinkPassword.transform(p -> String.format(GROUPSV2_GROUP_PASSWORD, Base64UrlSafe.encodeBytesWithoutPadding(p)))
|
||||||
|
.or(GROUPSV2_GROUP);
|
||||||
|
|
||||||
ResponseBody response = makeStorageRequest(authorization,
|
ResponseBody response = makeStorageRequest(authorization,
|
||||||
GROUPSV2_GROUP,
|
path,
|
||||||
"PATCH",
|
"PATCH",
|
||||||
protobufRequestBody(groupChange),
|
protobufRequestBody(groupChange),
|
||||||
GROUPS_V2_PATCH_RESPONSE_HANDLER);
|
GROUPS_V2_PATCH_RESPONSE_HANDLER);
|
||||||
@ -1981,14 +1984,15 @@ public class PushServiceSocket {
|
|||||||
return GroupChanges.parseFrom(readBodyBytes(response));
|
return GroupChanges.parseFrom(readBodyBytes(response));
|
||||||
}
|
}
|
||||||
|
|
||||||
public GroupJoinInfo getGroupJoinInfo(byte[] groupLinkPassword, GroupsV2AuthorizationString authorization)
|
public GroupJoinInfo getGroupJoinInfo(Optional<byte[]> groupLinkPassword, GroupsV2AuthorizationString authorization)
|
||||||
throws NonSuccessfulResponseCodeException, PushNetworkException, InvalidProtocolBufferException
|
throws NonSuccessfulResponseCodeException, PushNetworkException, InvalidProtocolBufferException
|
||||||
{
|
{
|
||||||
ResponseBody response = makeStorageRequest(authorization.toString(),
|
String passwordParam = groupLinkPassword.transform(Base64UrlSafe::encodeBytesWithoutPadding).or("");
|
||||||
String.format(GROUPSV2_GROUP_JOIN, Base64UrlSafe.encodeBytesWithoutPadding(groupLinkPassword)),
|
ResponseBody response = makeStorageRequest(authorization.toString(),
|
||||||
"GET",
|
String.format(GROUPSV2_GROUP_JOIN, passwordParam),
|
||||||
null,
|
"GET",
|
||||||
GROUPS_V2_GET_JOIN_INFO_HANDLER);
|
null,
|
||||||
|
GROUPS_V2_GET_JOIN_INFO_HANDLER);
|
||||||
|
|
||||||
return GroupJoinInfo.parseFrom(readBodyBytes(response));
|
return GroupJoinInfo.parseFrom(readBodyBytes(response));
|
||||||
}
|
}
|
||||||
|
@ -95,9 +95,10 @@ message DecryptedTimer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
message DecryptedGroupJoinInfo {
|
message DecryptedGroupJoinInfo {
|
||||||
string title = 2;
|
string title = 2;
|
||||||
string avatar = 3;
|
string avatar = 3;
|
||||||
uint32 memberCount = 4;
|
uint32 memberCount = 4;
|
||||||
AccessControl.AccessRequired addFromInviteLink = 5;
|
AccessControl.AccessRequired addFromInviteLink = 5;
|
||||||
uint32 revision = 6;
|
uint32 revision = 6;
|
||||||
|
bool pendingAdminApproval = 7;
|
||||||
}
|
}
|
||||||
|
@ -202,10 +202,11 @@ message GroupInviteLink {
|
|||||||
}
|
}
|
||||||
|
|
||||||
message GroupJoinInfo {
|
message GroupJoinInfo {
|
||||||
bytes publicKey = 1;
|
bytes publicKey = 1;
|
||||||
bytes title = 2;
|
bytes title = 2;
|
||||||
string avatar = 3;
|
string avatar = 3;
|
||||||
uint32 memberCount = 4;
|
uint32 memberCount = 4;
|
||||||
AccessControl.AccessRequired addFromInviteLink = 5;
|
AccessControl.AccessRequired addFromInviteLink = 5;
|
||||||
uint32 revision = 6;
|
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.
|
* Reflects over the generated protobuf class and ensures that no new fields have been added since we wrote this.
|
||||||
* <p>
|
* <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
|
@Test
|
||||||
public void ensure_resolveConflict_knows_about_all_fields_of_DecryptedGroupChange() {
|
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.
|
* Reflects over the generated protobuf class and ensures that no new fields have been added since we wrote this.
|
||||||
* <p>
|
* <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
|
@Test
|
||||||
public void ensure_resolveConflict_knows_about_all_fields_of_GroupChange() {
|
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.
|
* Reflects over the generated protobuf class and ensures that no new fields have been added since we wrote this.
|
||||||
* <p>
|
* <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
|
@Test
|
||||||
public void ensure_resolveConflict_knows_about_all_fields_of_DecryptedGroup() {
|
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 org.whispersystems.signalservice.testutil.ZkGroupLibraryUtil;
|
||||||
|
|
||||||
import static org.junit.Assert.assertEquals;
|
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;
|
import static org.whispersystems.signalservice.api.groupsv2.ProtobufTestUtils.getMaxDeclaredFieldNumber;
|
||||||
|
|
||||||
public final class GroupsV2Operations_decrypt_groupJoinInfo_Test {
|
public final class GroupsV2Operations_decrypt_groupJoinInfo_Test {
|
||||||
@ -39,7 +41,7 @@ public final class GroupsV2Operations_decrypt_groupJoinInfo_Test {
|
|||||||
int maxFieldFound = getMaxDeclaredFieldNumber(GroupJoinInfo.class);
|
int maxFieldFound = getMaxDeclaredFieldNumber(GroupJoinInfo.class);
|
||||||
|
|
||||||
assertEquals("GroupOperations and its tests need updating to account for new fields on " + GroupJoinInfo.class.getName(),
|
assertEquals("GroupOperations and its tests need updating to account for new fields on " + GroupJoinInfo.class.getName(),
|
||||||
6, maxFieldFound);
|
7, maxFieldFound);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -107,4 +109,26 @@ public final class GroupsV2Operations_decrypt_groupJoinInfo_Test {
|
|||||||
|
|
||||||
assertEquals(11, decryptedGroupJoinInfo.getRevision());
|
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