Join group via invite link.

This commit is contained in:
Alan Evans 2020-08-26 12:51:25 -03:00 committed by GitHub
parent b58376920f
commit 860f06ec9e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 2488 additions and 271 deletions

View File

@ -227,6 +227,7 @@ import org.thoughtcrime.securesms.stickers.StickerLocator;
import org.thoughtcrime.securesms.stickers.StickerManagementActivity;
import org.thoughtcrime.securesms.stickers.StickerPackInstallEvent;
import org.thoughtcrime.securesms.stickers.StickerSearchRepository;
import org.thoughtcrime.securesms.util.AsynchronousCallback;
import org.thoughtcrime.securesms.util.Base64;
import org.thoughtcrime.securesms.util.BitmapUtil;
import org.thoughtcrime.securesms.util.CharacterCalculator.CharacterState;
@ -356,6 +357,8 @@ public class ConversationActivity extends PassphraseRequiredActivity
private InputPanel inputPanel;
private View panelParent;
private View noLongerMemberBanner;
private View requestingMemberBanner;
private View cancelJoinRequest;
private Stub<View> mentionsSuggestions;
private LinkPreviewViewModel linkPreviewViewModel;
@ -383,12 +386,21 @@ public class ConversationActivity extends PassphraseRequiredActivity
long threadId,
int distributionType,
int startingPosition)
{
Intent intent = buildIntent(context, recipientId, threadId);
intent.putExtra(ConversationActivity.DISTRIBUTION_TYPE_EXTRA, distributionType);
intent.putExtra(ConversationActivity.STARTING_POSITION_EXTRA, startingPosition);
return intent;
}
public static @NonNull Intent buildIntent(@NonNull Context context,
@NonNull RecipientId recipientId,
long threadId)
{
Intent intent = new Intent(context, ConversationActivity.class);
intent.putExtra(ConversationActivity.RECIPIENT_EXTRA, recipientId);
intent.putExtra(ConversationActivity.THREAD_ID_EXTRA, threadId);
intent.putExtra(ConversationActivity.DISTRIBUTION_TYPE_EXTRA, distributionType);
intent.putExtra(ConversationActivity.STARTING_POSITION_EXTRA, startingPosition);
return intent;
}
@ -1452,14 +1464,64 @@ public class ConversationActivity extends PassphraseRequiredActivity
}
private void initializeEnabledCheck() {
groupViewModel.getGroupActiveState().observe(this, state -> {
boolean inactivePushGroup = state != null && isPushGroupConversation() && !state.isActiveGroup();
boolean enabled = !inactivePushGroup;
noLongerMemberBanner.setVisibility(enabled ? View.GONE : View.VISIBLE);
inputPanel.setVisibility(enabled ? View.VISIBLE : View.GONE);
inputPanel.setEnabled(enabled);
sendButton.setEnabled(enabled);
attachButton.setEnabled(enabled);
groupViewModel.getSelfMemberLevel().observe(this, selfMemberShip -> {
boolean canSendMessages;
boolean leftGroup;
boolean canCancelRequest;
if (selfMemberShip == null) {
leftGroup = false;
canSendMessages = true;
canCancelRequest = false;
} else {
switch (selfMemberShip) {
case NOT_A_MEMBER:
leftGroup = true;
canSendMessages = false;
canCancelRequest = false;
break;
case PENDING_MEMBER:
leftGroup = false;
canSendMessages = false;
canCancelRequest = false;
break;
case REQUESTING_MEMBER:
leftGroup = false;
canSendMessages = false;
canCancelRequest = true;
break;
case FULL_MEMBER:
case ADMINISTRATOR:
leftGroup = false;
canSendMessages = true;
canCancelRequest = false;
break;
default:
throw new AssertionError();
}
}
noLongerMemberBanner.setVisibility(leftGroup ? View.VISIBLE : View.GONE);
requestingMemberBanner.setVisibility(canCancelRequest ? View.VISIBLE : View.GONE);
if (canCancelRequest) {
cancelJoinRequest.setOnClickListener(v -> ConversationGroupViewModel.onCancelJoinRequest(getRecipient(), new AsynchronousCallback.MainThread<Void, GroupChangeFailureReason>() {
@Override
public void onComplete(@Nullable Void result) {
Log.d(TAG, "Cancel request complete");
}
@Override
public void onError(@Nullable GroupChangeFailureReason error) {
Log.d(TAG, "Cancel join request failed " + error);
Toast.makeText(ConversationActivity.this, GroupErrors.getUserDisplayMessage(error), Toast.LENGTH_SHORT).show();
}
}.toWorkerCallback()));
}
inputPanel.setVisibility(canSendMessages ? View.VISIBLE : View.GONE);
inputPanel.setEnabled(canSendMessages);
sendButton.setEnabled(canSendMessages);
attachButton.setEnabled(canSendMessages);
});
}
@ -1755,6 +1817,8 @@ public class ConversationActivity extends PassphraseRequiredActivity
ImageButton inlineAttachmentButton = ViewUtil.findById(this, R.id.inline_attachment_button);
noLongerMemberBanner = findViewById(R.id.conversation_no_longer_member_banner);
requestingMemberBanner = findViewById(R.id.conversation_requesting_banner);
cancelJoinRequest = findViewById(R.id.conversation_cancel_request);
container.addOnKeyboardShownListener(this);
inputPanel.setListener(this);

View File

@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.conversation;
import android.content.Context;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.view.View;
import android.widget.TextView;
@ -54,6 +55,7 @@ public class ConversationBannerView extends ConstraintLayout {
public void setSubtitle(@Nullable CharSequence subtitle) {
contactSubtitle.setText(subtitle);
contactSubtitle.setVisibility(TextUtils.isEmpty(subtitle) ? GONE : VISIBLE);
}
public void setDescription(@Nullable CharSequence description) {

View File

@ -77,8 +77,8 @@ import org.thoughtcrime.securesms.conversation.ConversationAdapter.ItemClickList
import org.thoughtcrime.securesms.conversation.ConversationAdapter.StickyHeaderViewHolder;
import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MmsDatabase;
import org.thoughtcrime.securesms.database.MessageDatabase;
import org.thoughtcrime.securesms.database.MmsDatabase;
import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.database.SmsDatabase;
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
@ -402,9 +402,11 @@ public class ConversationFragment extends LoggingFragment {
conversationBanner.setSubtitle(context.getResources()
.getQuantityString(R.plurals.MessageRequestProfileView_members_and_invited, memberCount,
memberCount, pendingMemberCount));
} else {
} else if (memberCount > 0) {
conversationBanner.setSubtitle(context.getResources().getQuantityString(R.plurals.MessageRequestProfileView_members, memberCount,
memberCount));
} else {
conversationBanner.setSubtitle(null);
}
} else if (isSelf) {
conversationBanner.setSubtitle(context.getString(R.string.ConversationFragment__you_can_add_notes_for_yourself_in_this_conversation));

View File

@ -14,18 +14,30 @@ import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.GroupDatabase;
import org.thoughtcrime.securesms.database.GroupDatabase.GroupRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.groups.GroupChangeBusyException;
import org.thoughtcrime.securesms.groups.GroupChangeFailedException;
import org.thoughtcrime.securesms.groups.GroupManager;
import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.AsynchronousCallback;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
class ConversationGroupViewModel extends ViewModel {
import java.io.IOException;
final class ConversationGroupViewModel extends ViewModel {
private final MutableLiveData<Recipient> liveRecipient;
private final LiveData<GroupActiveState> groupActiveState;
private final LiveData<GroupDatabase.MemberLevel> selfMembershipLevel;
private ConversationGroupViewModel() {
liveRecipient = new MutableLiveData<>();
LiveData<GroupRecord> groupRecord = LiveDataUtil.mapAsync(liveRecipient, this::getGroupRecordForRecipient);
groupActiveState = Transformations.distinctUntilChanged(Transformations.map(groupRecord, this::mapToGroupActiveState));
this.liveRecipient = new MutableLiveData<>();
LiveData<GroupRecord> groupRecord = LiveDataUtil.mapAsync(liveRecipient, ConversationGroupViewModel::getGroupRecordForRecipient);
this.groupActiveState = Transformations.distinctUntilChanged(Transformations.map(groupRecord, ConversationGroupViewModel::mapToGroupActiveState));
this.selfMembershipLevel = Transformations.distinctUntilChanged(Transformations.map(groupRecord, ConversationGroupViewModel::mapToSelfMembershipLevel));
}
void onRecipientChange(Recipient recipient) {
@ -36,7 +48,11 @@ class ConversationGroupViewModel extends ViewModel {
return groupActiveState;
}
private GroupRecord getGroupRecordForRecipient(Recipient recipient) {
LiveData<GroupDatabase.MemberLevel> getSelfMemberLevel() {
return selfMembershipLevel;
}
private static @Nullable GroupRecord getGroupRecordForRecipient(@Nullable Recipient recipient) {
if (recipient != null && recipient.isGroup()) {
Application context = ApplicationDependencies.getApplication();
GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context);
@ -46,13 +62,37 @@ class ConversationGroupViewModel extends ViewModel {
}
}
private GroupActiveState mapToGroupActiveState(@Nullable GroupRecord record) {
private static GroupActiveState mapToGroupActiveState(@Nullable GroupRecord record) {
if (record == null) {
return null;
}
return new GroupActiveState(record.isActive(), record.isV2Group());
}
private static GroupDatabase.MemberLevel mapToSelfMembershipLevel(@Nullable GroupRecord record) {
if (record == null) {
return null;
}
return record.memberLevel(Recipient.self());
}
public static void onCancelJoinRequest(@NonNull Recipient recipient,
@NonNull AsynchronousCallback.WorkerThread<Void, GroupChangeFailureReason> callback)
{
SignalExecutors.UNBOUNDED.execute(() -> {
if (!recipient.isPushV2Group()) {
throw new AssertionError();
}
try {
GroupManager.cancelJoinRequest(ApplicationDependencies.getApplication(), recipient.getGroupId().get().requireV2());
callback.onComplete(null);
} catch (GroupChangeFailedException | GroupChangeBusyException | IOException e) {
callback.onError(GroupChangeFailureReason.fromException(e));
}
});
}
static final class GroupActiveState {
private final boolean isActive;
private final boolean isActiveV2;

View File

@ -129,6 +129,15 @@ public final class GroupDatabase extends Database {
}
}
public boolean findGroup(@NonNull GroupId groupId) {
try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, GROUP_ID + " = ?",
new String[] {groupId.toString()},
null, null, null))
{
return cursor.moveToNext();
}
}
Optional<GroupRecord> getGroup(Cursor cursor) {
Reader reader = new Reader(cursor);
return Optional.fromNullable(reader.getCurrent());
@ -364,7 +373,13 @@ public final class GroupDatabase extends Database {
contentValues.put(AVATAR_RELAY, relay);
contentValues.put(TIMESTAMP, System.currentTimeMillis());
if (groupId.isV2()) {
contentValues.put(ACTIVE, groupState != null && gv2GroupActive(groupState) ? 1 : 0);
} else {
contentValues.put(ACTIVE, 1);
}
contentValues.put(MMS, groupId.isMms());
if (groupMasterKey != null) {
@ -428,14 +443,12 @@ public final class GroupDatabase extends Database {
RecipientId groupRecipientId = recipientDatabase.getOrInsertFromGroupId(groupId);
String title = decryptedGroup.getTitle();
ContentValues contentValues = new ContentValues();
UUID uuid = Recipient.self().getUuid().get();
contentValues.put(TITLE, title);
contentValues.put(V2_REVISION, decryptedGroup.getRevision());
contentValues.put(V2_DECRYPTED_GROUP, decryptedGroup.toByteArray());
contentValues.put(MEMBERS, serializeV2GroupMembers(decryptedGroup));
contentValues.put(ACTIVE, DecryptedGroupUtil.findMemberByUuid(decryptedGroup.getMembersList(), uuid).isPresent() ||
DecryptedGroupUtil.findPendingByUuid(decryptedGroup.getPendingMembersList(), uuid).isPresent() ? 1 : 0);
contentValues.put(ACTIVE, gv2GroupActive(decryptedGroup) ? 1 : 0);
databaseHelper.getWritableDatabase().update(TABLE_NAME, contentValues,
GROUP_ID + " = ?",
@ -502,6 +515,13 @@ public final class GroupDatabase extends Database {
Recipient.live(groupRecipient).refresh();
}
private static boolean gv2GroupActive(@NonNull DecryptedGroup decryptedGroup) {
UUID uuid = Recipient.self().getUuid().get();
return DecryptedGroupUtil.findMemberByUuid(decryptedGroup.getMembersList(), uuid).isPresent() ||
DecryptedGroupUtil.findPendingByUuid(decryptedGroup.getPendingMembersList(), uuid).isPresent();
}
private List<RecipientId> getCurrentMembers(@NonNull GroupId groupId) {
Cursor cursor = null;
@ -843,8 +863,10 @@ public final class GroupDatabase extends Database {
? MemberLevel.ADMINISTRATOR
: MemberLevel.FULL_MEMBER)
.or(() -> DecryptedGroupUtil.findPendingByUuid(decryptedGroup.getPendingMembersList(), recipient.getUuid().get())
.isPresent() ? MemberLevel.PENDING_MEMBER
: MemberLevel.NOT_A_MEMBER);
.transform(m -> MemberLevel.PENDING_MEMBER)
.or(() -> DecryptedGroupUtil.findRequestingByUuid(decryptedGroup.getRequestingMembersList(), recipient.getUuid().get())
.transform(m -> MemberLevel.REQUESTING_MEMBER)
.or(MemberLevel.NOT_A_MEMBER)));
}
public List<Recipient> getMemberRecipients(@NonNull MemberSet memberSet) {
@ -902,6 +924,7 @@ public final class GroupDatabase extends Database {
public enum MemberLevel {
NOT_A_MEMBER(false),
PENDING_MEMBER(false),
REQUESTING_MEMBER(false),
FULL_MEMBER(true),
ADMINISTRATOR(true);

View File

@ -583,11 +583,17 @@ final class GroupsV2UpdateMessageProducer {
if (requestingMemberIsYou) {
updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_approved_your_request_to_join_the_group, editor)));
} else {
boolean editorIsYou = change.getEditor().equals(selfUuidBytes);
if (editorIsYou) {
updates.add(updateDescription(requestingMember.getUuid(), requesting -> context.getString(R.string.MessageRecord_you_approved_a_request_to_join_the_group_from_s, requesting)));
} else {
updates.add(updateDescription(change.getEditor(), requestingMember.getUuid(), (editor, requesting) -> context.getString(R.string.MessageRecord_s_approved_a_request_to_join_the_group_from_s, editor, requesting)));
}
}
}
}
private void describeUnknownEditorRequestingMembersApprovals(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
for (DecryptedApproveMember requestingMember : change.getPromoteRequestingMembersList()) {
@ -602,16 +608,28 @@ final class GroupsV2UpdateMessageProducer {
}
private void describeRequestingMembersDeletes(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
boolean editorIsYou = change.getEditor().equals(selfUuidBytes);
for (ByteString requestingMember : change.getDeleteRequestingMembersList()) {
boolean requestingMemberIsYou = requestingMember.equals(selfUuidBytes);
if (requestingMemberIsYou) {
if (editorIsYou) {
updates.add(updateDescription(context.getString(R.string.MessageRecord_you_canceled_your_request_to_join_the_group)));
} else {
updates.add(updateDescription(context.getString(R.string.MessageRecord_your_request_to_join_the_group_has_been_denied_by_an_admin)));
}
} else {
boolean editorIsCanceledMember = change.getEditor().equals(requestingMember);
if (editorIsCanceledMember) {
updates.add(updateDescription(requestingMember, editorRequesting -> context.getString(R.string.MessageRecord_s_canceled_their_request_to_join_the_group, editorRequesting)));
} else {
updates.add(updateDescription(change.getEditor(), requestingMember, (editor, requesting) -> context.getString(R.string.MessageRecord_s_denied_a_request_to_join_the_group_from_s, editor, requesting)));
}
}
}
}
private void describeUnknownEditorRequestingMembersDeletes(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
for (ByteString requestingMember : change.getDeleteRequestingMembersList()) {

View File

@ -174,7 +174,7 @@ public abstract class MessageRecord extends DisplayRecord {
DecryptedGroupV2Context decryptedGroupV2Context = DecryptedGroupV2Context.parseFrom(decoded);
GroupsV2UpdateMessageProducer updateMessageProducer = new GroupsV2UpdateMessageProducer(context, descriptionStrategy, Recipient.self().getUuid().get());
if (decryptedGroupV2Context.hasChange() && decryptedGroupV2Context.getGroupState().getRevision() > 0) {
if (decryptedGroupV2Context.hasChange() && decryptedGroupV2Context.getGroupState().getRevision() != 0) {
return UpdateDescription.concatWithNewLines(updateMessageProducer.describeChanges(decryptedGroupV2Context.getChange()));
} else {
return updateMessageProducer.describeNewGroup(decryptedGroupV2Context.getGroupState());

View File

@ -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);
}
}

View File

@ -145,6 +145,12 @@ public final class GroupManager {
}
}
/**
* @throws GroupNotAMemberException When Self is not a member of the group.
* The exception to this is when Self is a requesting member and
* there is a supplied signedGroupChange. This allows for
* processing deny messages.
*/
@WorkerThread
public static void updateGroupFromServer(@NonNull Context context,
@NonNull GroupMasterKey groupMasterKey,
@ -174,6 +180,11 @@ public final class GroupManager {
public static void updateSelfProfileKeyInGroup(@NonNull Context context, @NonNull GroupId.V2 groupId)
throws IOException, GroupChangeBusyException, GroupInsufficientRightsException, GroupNotAMemberException, GroupChangeFailedException
{
if (!DatabaseFactory.getGroupDatabase(context).findGroup(groupId)) {
Log.i(TAG, "Group is not available locally " + groupId);
return;
}
try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) {
editor.updateSelfProfileKeyInGroup();
}
@ -266,12 +277,35 @@ public final class GroupManager {
@WorkerThread
public static @NonNull DecryptedGroupJoinInfo getGroupJoinInfoFromServer(@NonNull Context context,
@NonNull GroupMasterKey groupMasterKey,
@NonNull GroupLinkPassword groupLinkPassword)
@Nullable GroupLinkPassword groupLinkPassword)
throws IOException, VerificationFailedException, GroupLinkNotActiveException
{
return new GroupManagerV2(context).getGroupJoinInfoFromServer(groupMasterKey, groupLinkPassword);
}
@WorkerThread
public static GroupActionResult joinGroup(@NonNull Context context,
@NonNull GroupMasterKey groupMasterKey,
@NonNull GroupLinkPassword groupLinkPassword,
@NonNull DecryptedGroupJoinInfo decryptedGroupJoinInfo,
@Nullable byte[] avatar)
throws IOException, GroupChangeBusyException, GroupChangeFailedException, MembershipNotSuitableForV2Exception, GroupLinkNotActiveException
{
try (GroupManagerV2.GroupJoiner join = new GroupManagerV2(context).join(groupMasterKey, groupLinkPassword)) {
return join.joinGroup(decryptedGroupJoinInfo, avatar);
}
}
@WorkerThread
public static void cancelJoinRequest(@NonNull Context context,
@NonNull GroupId.V2 groupId)
throws GroupChangeFailedException, IOException, GroupChangeBusyException
{
try (GroupManagerV2.GroupJoiner editor = new GroupManagerV2(context).cancelRequest(groupId.requireV2())) {
editor.cancelJoinRequest();
}
}
public static class GroupActionResult {
private final Recipient groupRecipient;
private final long threadId;

View File

@ -6,7 +6,9 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import com.annimon.stream.Collectors;
import com.annimon.stream.Stream;
import com.google.protobuf.ByteString;
import com.google.protobuf.InvalidProtocolBufferException;
import org.signal.storageservice.protos.groups.AccessControl;
@ -17,21 +19,25 @@ import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
import org.signal.storageservice.protos.groups.local.DecryptedGroupJoinInfo;
import org.signal.storageservice.protos.groups.local.DecryptedMember;
import org.signal.storageservice.protos.groups.local.DecryptedPendingMember;
import org.signal.storageservice.protos.groups.local.DecryptedRequestingMember;
import org.signal.zkgroup.InvalidInputException;
import org.signal.zkgroup.VerificationFailedException;
import org.signal.zkgroup.groups.GroupMasterKey;
import org.signal.zkgroup.groups.GroupSecretParams;
import org.signal.zkgroup.groups.UuidCiphertext;
import org.signal.zkgroup.profiles.ProfileKey;
import org.signal.zkgroup.profiles.ProfileKeyCredential;
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.GroupDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.groups.v2.GroupCandidateHelper;
import org.thoughtcrime.securesms.groups.v2.GroupLinkPassword;
import org.thoughtcrime.securesms.groups.v2.processing.GroupsV2StateProcessor;
import org.thoughtcrime.securesms.jobs.PushGroupSilentUpdateSendJob;
import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.OutgoingGroupUpdateMessage;
@ -62,6 +68,7 @@ import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
@ -89,12 +96,14 @@ final class GroupManagerV2 {
this.groupCandidateHelper = new GroupCandidateHelper(context);
}
@NonNull DecryptedGroupJoinInfo getGroupJoinInfoFromServer(@NonNull GroupMasterKey groupMasterKey, @NonNull GroupLinkPassword password)
@NonNull DecryptedGroupJoinInfo getGroupJoinInfoFromServer(@NonNull GroupMasterKey groupMasterKey, @Nullable GroupLinkPassword password)
throws IOException, VerificationFailedException, GroupLinkNotActiveException
{
GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
return groupsV2Api.getGroupJoinInfo(groupSecretParams, password.serialize(), authorization.getAuthorizationForToday(Recipient.self().requireUuid(), groupSecretParams));
return groupsV2Api.getGroupJoinInfo(groupSecretParams,
Optional.fromNullable(password).transform(GroupLinkPassword::serialize),
authorization.getAuthorizationForToday(Recipient.self().requireUuid(), groupSecretParams));
}
@WorkerThread
@ -107,17 +116,30 @@ final class GroupManagerV2 {
return new GroupEditor(groupId, GroupsV2ProcessingLock.acquireGroupProcessingLock());
}
@WorkerThread
GroupJoiner join(@NonNull GroupMasterKey groupMasterKey, @NonNull GroupLinkPassword password) throws GroupChangeBusyException {
return new GroupJoiner(groupMasterKey, password, GroupsV2ProcessingLock.acquireGroupProcessingLock());
}
@WorkerThread
GroupJoiner cancelRequest(@NonNull GroupId.V2 groupId) throws GroupChangeBusyException {
GroupMasterKey groupMasterKey = DatabaseFactory.getGroupDatabase(context)
.requireGroup(groupId)
.requireV2GroupProperties()
.getGroupMasterKey();
return new GroupJoiner(groupMasterKey, null, GroupsV2ProcessingLock.acquireGroupProcessingLock());
}
@WorkerThread
GroupUpdater updater(@NonNull GroupMasterKey groupId) throws GroupChangeBusyException {
return new GroupUpdater(groupId, GroupsV2ProcessingLock.acquireGroupProcessingLock());
}
class GroupCreator implements Closeable {
private final Closeable lock;
final class GroupCreator extends LockOwner {
GroupCreator(@NonNull Closeable lock) {
this.lock = lock;
super(lock);
}
@WorkerThread
@ -176,26 +198,21 @@ final class GroupManagerV2 {
throw new GroupChangeFailedException(e);
}
}
@Override
public void close() throws IOException {
lock.close();
}
}
class GroupEditor implements Closeable {
final class GroupEditor extends LockOwner {
private final Closeable lock;
private final GroupId.V2 groupId;
private final GroupMasterKey groupMasterKey;
private final GroupSecretParams groupSecretParams;
private final GroupsV2Operations.GroupOperations groupOperations;
GroupEditor(@NonNull GroupId.V2 groupId, @NonNull Closeable lock) {
super(lock);
GroupDatabase.GroupRecord groupRecord = groupDatabase.requireGroup(groupId);
GroupDatabase.V2GroupProperties v2GroupProperties = groupRecord.requireV2GroupProperties();
this.lock = lock;
this.groupId = groupId;
this.groupMasterKey = v2GroupProperties.getGroupMasterKey();
this.groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
@ -275,6 +292,17 @@ final class GroupManagerV2 {
return commitChangeWithConflictResolution(groupOperations.createRemoveInvitationChange(new HashSet<>(uuidCipherTexts)));
}
@WorkerThread
@NonNull GroupManager.GroupActionResult denyRequests(@NonNull Collection<RecipientId> recipientIds)
throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException
{
Set<UUID> uuids = Stream.of(recipientIds)
.map(r -> Recipient.resolved(r).getUuid().get())
.collect(Collectors.toSet());
return commitChangeWithConflictResolution(groupOperations.createRefuseGroupJoinRequest(uuids));
}
@WorkerThread
@NonNull GroupManager.GroupActionResult setMemberAdmin(@NonNull RecipientId recipientId,
boolean admin)
@ -333,7 +361,7 @@ final class GroupManagerV2 {
Optional<DecryptedMember> selfInGroup = DecryptedGroupUtil.findMemberByUuid(group.getMembersList(), selfUuid);
if (!selfInGroup.isPresent()) {
Log.w(TAG, "Self not in group");
Log.w(TAG, "Self not in group " + groupId);
return null;
}
@ -456,7 +484,7 @@ final class GroupManagerV2 {
throws GroupNotAMemberException, GroupChangeFailedException, IOException, GroupInsufficientRightsException
{
try {
return groupsV2Api.patchGroup(change, authorization.getAuthorizationForToday(selfUuid, groupSecretParams));
return groupsV2Api.patchGroup(change, authorization.getAuthorizationForToday(selfUuid, groupSecretParams), Optional.absent());
} catch (NotInGroupException e) {
Log.w(TAG, e);
throw new GroupNotAMemberException(e);
@ -468,20 +496,15 @@ final class GroupManagerV2 {
throw new GroupChangeFailedException(e);
}
}
@Override
public void close() throws IOException {
lock.close();
}
}
class GroupUpdater implements Closeable {
final class GroupUpdater extends LockOwner {
private final Closeable lock;
private final GroupMasterKey groupMasterKey;
GroupUpdater(@NonNull GroupMasterKey groupMasterKey, @NonNull Closeable lock) {
this.lock = lock;
super(lock);
this.groupMasterKey = groupMasterKey;
}
@ -507,6 +530,377 @@ final class GroupManagerV2 {
return null;
}
}
final class GroupJoiner extends LockOwner {
private final GroupId.V2 groupId;
private final GroupLinkPassword password;
private final GroupSecretParams groupSecretParams;
private final GroupsV2Operations.GroupOperations groupOperations;
private final GroupMasterKey groupMasterKey;
public GroupJoiner(@NonNull GroupMasterKey groupMasterKey,
@Nullable GroupLinkPassword password,
@NonNull Closeable lock)
{
super(lock);
this.groupId = GroupId.v2(groupMasterKey);
this.password = password;
this.groupMasterKey = groupMasterKey;
this.groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
this.groupOperations = groupsV2Operations.forGroup(groupSecretParams);
}
@WorkerThread
public GroupManager.GroupActionResult joinGroup(@NonNull DecryptedGroupJoinInfo joinInfo,
@Nullable byte[] avatar)
throws GroupChangeFailedException, IOException, MembershipNotSuitableForV2Exception, GroupLinkNotActiveException
{
boolean requestToJoin = joinInfo.getAddFromInviteLink() == AccessControl.AccessRequired.ADMINISTRATOR;
boolean alreadyAMember = false;
if (requestToJoin) {
Log.i(TAG, "Requesting to join " + groupId);
} else {
Log.i(TAG, "Joining " + groupId);
}
GroupChange signedGroupChange = null;
DecryptedGroupChange decryptedChange = null;
try {
signedGroupChange = joinGroupOnServer(requestToJoin, joinInfo.getRevision());
if (requestToJoin) {
Log.i(TAG, String.format("Successfully requested to join %s on server", groupId));
} else {
Log.i(TAG, String.format("Successfully added self to %s on server", groupId));
}
decryptedChange = decryptChange(signedGroupChange);
} catch (GroupJoinAlreadyAMemberException e) {
Log.i(TAG, "Server reports that we are already a member of " + groupId);
alreadyAMember = true;
}
DecryptedGroup decryptedGroup = createPlaceholderGroup(joinInfo, requestToJoin);
Optional<GroupDatabase.GroupRecord> group = groupDatabase.getGroup(groupId);
if (group.isPresent()) {
Log.i(TAG, "Group already present locally");
DecryptedGroup currentGroupState = group.get()
.requireV2GroupProperties()
.getDecryptedGroup();
DecryptedGroup updatedGroup = currentGroupState;
try {
if (decryptedChange != null) {
updatedGroup = DecryptedGroupUtil.applyWithoutRevisionCheck(updatedGroup, decryptedChange);
}
updatedGroup = resetRevision(updatedGroup, currentGroupState.getRevision());
} catch (NotAbleToApplyGroupV2ChangeException e) {
Log.w(TAG, e);
updatedGroup = decryptedGroup;
}
groupDatabase.update(groupId, updatedGroup);
} else {
groupDatabase.create(groupMasterKey, decryptedGroup);
Log.i(TAG, "Created local group with placeholder");
}
RecipientId groupRecipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId);
Recipient groupRecipient = Recipient.resolved(groupRecipientId);
AvatarHelper.setAvatar(context, groupRecipientId, avatar != null ? new ByteArrayInputStream(avatar) : null);
groupDatabase.onAvatarUpdated(groupId, avatar != null);
DatabaseFactory.getRecipientDatabase(context).setProfileSharing(groupRecipientId, true);
if (alreadyAMember) {
Log.i(TAG, "Already a member of the group");
ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context);
long threadId = threadDatabase.getOrCreateValidThreadId(groupRecipient, -1);
return new GroupManager.GroupActionResult(groupRecipient,
threadId,
0,
Collections.emptyList());
} else if (requestToJoin) {
Log.i(TAG, "Requested to join, cannot send update");
RecipientAndThread recipientAndThread = sendGroupUpdate(groupMasterKey, decryptedGroup, decryptedChange, signedGroupChange);
return new GroupManager.GroupActionResult(groupRecipient,
recipientAndThread.threadId,
0,
Collections.emptyList());
} else {
Log.i(TAG, "Joined group on server, fetching group state and sending update");
return fetchGroupStateAndSendUpdate(groupRecipient, decryptedGroup, decryptedChange, signedGroupChange);
}
}
private GroupManager.GroupActionResult fetchGroupStateAndSendUpdate(@NonNull Recipient groupRecipient,
@NonNull DecryptedGroup decryptedGroup,
@NonNull DecryptedGroupChange decryptedChange,
@NonNull GroupChange signedGroupChange)
throws GroupChangeFailedException, IOException
{
try {
new GroupsV2StateProcessor(context).forGroup(groupMasterKey)
.updateLocalGroupToRevision(decryptedChange.getRevision(),
System.currentTimeMillis(),
decryptedChange);
RecipientAndThread recipientAndThread = sendGroupUpdate(groupMasterKey, decryptedGroup, decryptedChange, signedGroupChange);
return new GroupManager.GroupActionResult(groupRecipient,
recipientAndThread.threadId,
1,
Collections.emptyList());
} catch (GroupNotAMemberException e) {
Log.w(TAG, "Despite adding self to group, server says we are not a member, scheduling refresh of group info " + groupId, e);
ApplicationDependencies.getJobManager()
.add(new RequestGroupV2InfoJob(groupId));
throw new GroupChangeFailedException(e);
} catch (IOException e) {
Log.w(TAG, "Group data fetch failed, scheduling refresh of group info " + groupId, e);
ApplicationDependencies.getJobManager()
.add(new RequestGroupV2InfoJob(groupId));
throw e;
}
}
private @NonNull DecryptedGroupChange decryptChange(@NonNull GroupChange signedGroupChange)
throws GroupChangeFailedException
{
try {
return groupOperations.decryptChange(signedGroupChange, false).get();
} catch (VerificationFailedException | InvalidGroupStateException | InvalidProtocolBufferException e) {
Log.w(TAG, e);
throw new GroupChangeFailedException(e);
}
}
/**
* Creates a local group from what we know before joining.
* <p>
* Creates as a {@link GroupsV2StateProcessor#PLACEHOLDER_REVISION} so that we know not do do a
* full diff against this group once we learn more about this group as that would create a large
* update message.
*/
private DecryptedGroup createPlaceholderGroup(@NonNull DecryptedGroupJoinInfo joinInfo, boolean requestToJoin) {
DecryptedGroup.Builder group = DecryptedGroup.newBuilder()
.setTitle(joinInfo.getTitle())
.setAvatar(joinInfo.getAvatar())
.setRevision(GroupsV2StateProcessor.PLACEHOLDER_REVISION);
Recipient self = Recipient.self();
ByteString selfUuid = UuidUtil.toByteString(self.requireUuid());
ByteString profileKey = ByteString.copyFrom(Objects.requireNonNull(self.getProfileKey()));
if (requestToJoin) {
group.addRequestingMembers(DecryptedRequestingMember.newBuilder()
.setUuid(selfUuid)
.setProfileKey(profileKey));
} else {
group.addMembers(DecryptedMember.newBuilder()
.setUuid(selfUuid)
.setProfileKey(profileKey));
}
return group.build();
}
private @NonNull GroupChange joinGroupOnServer(boolean requestToJoin, int currentRevision)
throws GroupChangeFailedException, IOException, MembershipNotSuitableForV2Exception, GroupLinkNotActiveException, GroupJoinAlreadyAMemberException
{
if (!GroupsV2CapabilityChecker.allAndSelfSupportGroupsV2AndUuid(Collections.singleton(Recipient.self().getId()))) {
throw new MembershipNotSuitableForV2Exception("Self does not support GV2 or UUID capabilities");
}
GroupCandidate self = groupCandidateHelper.recipientIdToCandidate(Recipient.self().getId());
if (!self.hasProfileKeyCredential()) {
throw new MembershipNotSuitableForV2Exception("No profile key credential for self");
}
ProfileKeyCredential profileKeyCredential = self.getProfileKeyCredential().get();
GroupChange.Actions.Builder change = requestToJoin ? groupOperations.createGroupJoinRequest(profileKeyCredential)
: groupOperations.createGroupJoinDirect(profileKeyCredential);
change.setSourceUuid(UuidUtil.toByteString(Recipient.self().getUuid().get()));
return commitJoinChangeWithConflictResolution(currentRevision, change);
}
private @NonNull GroupChange commitJoinChangeWithConflictResolution(int currentRevision, @NonNull GroupChange.Actions.Builder change)
throws GroupChangeFailedException, IOException, GroupLinkNotActiveException, GroupJoinAlreadyAMemberException
{
for (int attempt = 0; attempt < 5; attempt++) {
try {
GroupChange.Actions changeActions = change.setRevision(currentRevision + 1)
.build();
Log.i(TAG, "Trying to join group at V" + changeActions.getRevision());
GroupChange signedGroupChange = commitJoinToServer(changeActions);
Log.i(TAG, "Successfully joined group at V" + changeActions.getRevision());
return signedGroupChange;
} catch (GroupPatchNotAcceptedException e) {
Log.w(TAG, "Patch not accepted", e);
try {
if (alreadyPendingAdminApproval() || testGroupMembership()) {
throw new GroupJoinAlreadyAMemberException(e);
} else {
throw new GroupChangeFailedException(e);
}
} catch (VerificationFailedException | InvalidGroupStateException ex) {
throw new GroupChangeFailedException(ex);
}
} catch (ConflictException e) {
Log.w(TAG, "Revision conflict", e);
currentRevision = getCurrentGroupRevisionFromServer();
}
}
throw new GroupChangeFailedException("Unable to join group after conflicts");
}
private @NonNull GroupChange commitJoinToServer(@NonNull GroupChange.Actions change)
throws GroupChangeFailedException, IOException, GroupLinkNotActiveException
{
try {
return groupsV2Api.patchGroup(change, authorization.getAuthorizationForToday(selfUuid, groupSecretParams), Optional.fromNullable(password).transform(GroupLinkPassword::serialize));
} catch (NotInGroupException | VerificationFailedException e) {
Log.w(TAG, e);
throw new GroupChangeFailedException(e);
} catch (AuthorizationFailedException e) {
Log.w(TAG, e);
throw new GroupLinkNotActiveException(e);
}
}
private int getCurrentGroupRevisionFromServer()
throws IOException, GroupLinkNotActiveException, GroupChangeFailedException
{
try {
int currentRevision = getGroupJoinInfoFromServer(groupMasterKey, password).getRevision();
Log.i(TAG, "Server now on V" + currentRevision);
return currentRevision;
} catch (VerificationFailedException ex) {
throw new GroupChangeFailedException(ex);
}
}
private boolean alreadyPendingAdminApproval()
throws IOException, GroupLinkNotActiveException, GroupChangeFailedException
{
try {
boolean pendingAdminApproval = getGroupJoinInfoFromServer(groupMasterKey, password).getPendingAdminApproval();
if (pendingAdminApproval) {
Log.i(TAG, "User is already pending admin approval");
}
return pendingAdminApproval;
} catch (VerificationFailedException ex) {
throw new GroupChangeFailedException(ex);
}
}
private boolean testGroupMembership()
throws IOException, VerificationFailedException, InvalidGroupStateException
{
try {
groupsV2Api.getGroup(groupSecretParams, authorization.getAuthorizationForToday(Recipient.self().requireUuid(), groupSecretParams));
return true;
} catch (NotInGroupException ex) {
return false;
}
}
@WorkerThread
void cancelJoinRequest()
throws GroupChangeFailedException, IOException
{
Set<UUID> uuids = Collections.singleton(Recipient.self().getUuid().get());
GroupChange signedGroupChange;
try {
signedGroupChange = commitCancelChangeWithConflictResolution(groupOperations.createRefuseGroupJoinRequest(uuids));
} catch (GroupLinkNotActiveException e) {
Log.d(TAG, "Unexpected unable to leave group due to group link off");
throw new GroupChangeFailedException(e);
}
DecryptedGroup decryptedGroup = groupDatabase.requireGroup(groupId).requireV2GroupProperties().getDecryptedGroup();
try {
DecryptedGroupChange decryptedChange = groupOperations.decryptChange(signedGroupChange, false).get();
DecryptedGroup newGroup = DecryptedGroupUtil.applyWithoutRevisionCheck(decryptedGroup, decryptedChange);
groupDatabase.update(groupId, resetRevision(newGroup, decryptedGroup.getRevision()));
sendGroupUpdate(groupMasterKey, decryptedGroup, decryptedChange, signedGroupChange);
} catch (VerificationFailedException | InvalidGroupStateException | NotAbleToApplyGroupV2ChangeException e) {
throw new GroupChangeFailedException(e);
}
}
private DecryptedGroup resetRevision(DecryptedGroup newGroup, int revision) {
return DecryptedGroup.newBuilder(newGroup)
.setRevision(revision)
.build();
}
private @NonNull GroupChange commitCancelChangeWithConflictResolution(@NonNull GroupChange.Actions.Builder change)
throws GroupChangeFailedException, IOException, GroupLinkNotActiveException
{
int currentRevision = getCurrentGroupRevisionFromServer();
for (int attempt = 0; attempt < 5; attempt++) {
try {
GroupChange.Actions changeActions = change.setRevision(currentRevision + 1)
.build();
Log.i(TAG, "Trying to cancel request group at V" + changeActions.getRevision());
GroupChange signedGroupChange = commitJoinToServer(changeActions);
Log.i(TAG, "Successfully cancelled group join at V" + changeActions.getRevision());
return signedGroupChange;
} catch (GroupPatchNotAcceptedException e) {
throw new GroupChangeFailedException(e);
} catch (ConflictException e) {
Log.w(TAG, "Revision conflict", e);
currentRevision = getCurrentGroupRevisionFromServer();
}
}
throw new GroupChangeFailedException("Unable to cancel group join request after conflicts");
}
}
private abstract static class LockOwner implements Closeable {
final Closeable lock;
LockOwner(@NonNull Closeable lock) {
this.lock = lock;
}
@Override
public void close() throws IOException {

View File

@ -1,6 +1,6 @@
package org.thoughtcrime.securesms.groups.ui;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import org.thoughtcrime.securesms.R;
@ -9,7 +9,11 @@ public final class GroupErrors {
private GroupErrors() {
}
public static @StringRes int getUserDisplayMessage(@NonNull GroupChangeFailureReason failureReason) {
public static @StringRes int getUserDisplayMessage(@Nullable GroupChangeFailureReason failureReason) {
if (failureReason == null) {
return R.string.ManageGroupActivity_failed_to_update_the_group;
}
switch (failureReason) {
case NO_RIGHTS : return R.string.ManageGroupActivity_you_dont_have_the_rights_to_do_this;
case NOT_CAPABLE : return R.string.ManageGroupActivity_not_capable;

View File

@ -1,42 +1,39 @@
package org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.signal.storageservice.protos.groups.AccessControl;
import org.signal.storageservice.protos.groups.local.DecryptedGroupJoinInfo;
public final class GroupDetails {
private final String groupName;
private final DecryptedGroupJoinInfo joinInfo;
private final byte[] avatarBytes;
private final int groupMembershipCount;
private final boolean requiresAdminApproval;
private final int groupRevision;
public GroupDetails(String groupName,
byte[] avatarBytes,
int groupMembershipCount,
boolean requiresAdminApproval,
int groupRevision)
public GroupDetails(@NonNull DecryptedGroupJoinInfo joinInfo,
@Nullable byte[] avatarBytes)
{
this.groupName = groupName;
this.joinInfo = joinInfo;
this.avatarBytes = avatarBytes;
this.groupMembershipCount = groupMembershipCount;
this.requiresAdminApproval = requiresAdminApproval;
this.groupRevision = groupRevision;
}
public String getGroupName() {
return groupName;
public @NonNull String getGroupName() {
return joinInfo.getTitle();
}
public byte[] getAvatarBytes() {
public @Nullable byte[] getAvatarBytes() {
return avatarBytes;
}
public @NonNull DecryptedGroupJoinInfo getJoinInfo() {
return joinInfo;
}
public int getGroupMembershipCount() {
return groupMembershipCount;
return joinInfo.getMemberCount();
}
public boolean joinRequiresAdminApproval() {
return requiresAdminApproval;
}
public int getGroupRevision() {
return groupRevision;
return joinInfo.getAddFromInviteLink() == AccessControl.AccessRequired.ADMINISTRATOR;
}
}

View File

@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining;
import android.content.Intent;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
@ -22,7 +23,9 @@ import org.thoughtcrime.securesms.color.MaterialColor;
import org.thoughtcrime.securesms.components.AvatarImageView;
import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto;
import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto;
import org.thoughtcrime.securesms.conversation.ConversationActivity;
import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.BottomSheetUtil;
import org.thoughtcrime.securesms.util.FeatureFlags;
@ -31,6 +34,8 @@ import org.thoughtcrime.securesms.util.ThemeUtil;
public final class GroupJoinBottomSheetDialogFragment extends BottomSheetDialogFragment {
private static final String TAG = Log.tag(GroupJoinUpdateRequiredBottomSheetDialogFragment.class);
private static final String ARG_GROUP_INVITE_LINK_URL = "group_invite_url";
private ProgressBar busy;
@ -93,14 +98,13 @@ public final class GroupJoinBottomSheetDialogFragment extends BottomSheetDialogF
groupName.setText(details.getGroupName());
groupDetails.setText(requireContext().getResources().getQuantityString(R.plurals.GroupJoinBottomSheetDialogFragment_group_dot_d_members, details.getGroupMembershipCount(), details.getGroupMembershipCount()));
switch (FeatureFlags.clientLocalGroupJoinStatus()) {
switch (getGroupJoinStatus()) {
case COMING_SOON:
groupJoinExplain.setText(R.string.GroupJoinUpdateRequiredBottomSheetDialogFragment_coming_soon);
groupCancelButton.setText(android.R.string.ok);
groupJoinButton.setVisibility(View.GONE);
break;
case UPDATE_TO_JOIN:
case LOCAL_CAN_JOIN:
groupJoinExplain.setText(R.string.GroupJoinUpdateRequiredBottomSheetDialogFragment_update_message);
groupJoinButton.setText(R.string.GroupJoinUpdateRequiredBottomSheetDialogFragment_update_signal);
groupJoinButton.setOnClickListener(v -> {
@ -109,6 +113,17 @@ public final class GroupJoinBottomSheetDialogFragment extends BottomSheetDialogF
});
groupJoinButton.setVisibility(View.VISIBLE);
break;
case LOCAL_CAN_JOIN:
groupJoinExplain.setText(details.joinRequiresAdminApproval() ? R.string.GroupJoinBottomSheetDialogFragment_admin_approval_needed
: R.string.GroupJoinBottomSheetDialogFragment_direct_join);
groupJoinButton.setText(details.joinRequiresAdminApproval() ? R.string.GroupJoinBottomSheetDialogFragment_request_to_join
: R.string.GroupJoinBottomSheetDialogFragment_join);
groupJoinButton.setOnClickListener(v -> {
Log.i(TAG, details.joinRequiresAdminApproval() ? "Attempting to direct join group" : "Attempting to request to join group");
viewModel.join(details);
});
groupJoinButton.setVisibility(View.VISIBLE);
break;
}
avatar.setImageBytesForGroup(details.getAvatarBytes(), new FallbackPhotoProvider(), MaterialColor.STEEL);
@ -117,19 +132,55 @@ public final class GroupJoinBottomSheetDialogFragment extends BottomSheetDialogF
});
viewModel.isBusy().observe(getViewLifecycleOwner(), isBusy -> busy.setVisibility(isBusy ? View.VISIBLE : View.GONE));
viewModel.getErrors().observe(getViewLifecycleOwner(), error -> {
Toast.makeText(requireContext(), errorToMessage(error), Toast.LENGTH_SHORT).show();
dismiss();
});
viewModel.getJoinErrors().observe(getViewLifecycleOwner(), error -> Toast.makeText(requireContext(), errorToMessage(error), Toast.LENGTH_SHORT).show());
viewModel.getJoinSuccess().observe(getViewLifecycleOwner(), joinGroupSuccess -> {
Log.i(TAG, "Group joined, navigating to group");
Intent intent = ConversationActivity.buildIntent(requireContext(), joinGroupSuccess.getGroupRecipient().getId(), joinGroupSuccess.getGroupThreadId());
requireActivity().startActivity(intent);
dismiss();
}
);
}
protected @NonNull String errorToMessage(FetchGroupDetailsError error) {
private static FeatureFlags.GroupJoinStatus getGroupJoinStatus() {
FeatureFlags.GroupJoinStatus groupJoinStatus = FeatureFlags.clientLocalGroupJoinStatus();
if (groupJoinStatus == FeatureFlags.GroupJoinStatus.LOCAL_CAN_JOIN) {
if (!FeatureFlags.groupsV2() || Recipient.self().getGroupsV2Capability() == Recipient.Capability.NOT_SUPPORTED) {
// TODO [Alan] GV2 additional copy could be presented in these cases
return FeatureFlags.GroupJoinStatus.UPDATE_TO_JOIN;
}
return groupJoinStatus;
}
return groupJoinStatus;
}
private @NonNull String errorToMessage(@NonNull FetchGroupDetailsError error) {
if (error == FetchGroupDetailsError.GroupLinkNotActive) {
return getString(R.string.GroupJoinBottomSheetDialogFragment_this_group_link_is_not_active);
}
return getString(R.string.GroupJoinBottomSheetDialogFragment_unable_to_get_group_information_please_try_again_later);
}
private @NonNull String errorToMessage(@NonNull JoinGroupError error) {
switch (error) {
case GROUP_LINK_NOT_ACTIVE: return getString(R.string.GroupJoinBottomSheetDialogFragment_this_group_link_is_not_active);
case NETWORK_ERROR : return getString(R.string.GroupJoinBottomSheetDialogFragment_encountered_a_network_error);
default : return getString(R.string.GroupJoinBottomSheetDialogFragment_unable_to_join_group_please_try_again_later);
}
}
private GroupInviteLinkUrl getGroupInviteLinkUrl() {
try {
//noinspection ConstantConditions

View File

@ -5,15 +5,17 @@ import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import androidx.core.util.Consumer;
import org.signal.storageservice.protos.groups.AccessControl;
import org.signal.storageservice.protos.groups.local.DecryptedGroupJoinInfo;
import org.signal.zkgroup.VerificationFailedException;
import org.thoughtcrime.securesms.groups.GroupChangeBusyException;
import org.thoughtcrime.securesms.groups.GroupChangeFailedException;
import org.thoughtcrime.securesms.groups.GroupManager;
import org.thoughtcrime.securesms.groups.MembershipNotSuitableForV2Exception;
import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl;
import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.util.AsynchronousCallback;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException;
@ -31,7 +33,7 @@ final class GroupJoinRepository {
this.groupInviteLinkUrl = groupInviteLinkUrl;
}
void getGroupDetails(@NonNull GetGroupDetailsCallback callback) {
void getGroupDetails(@NonNull AsynchronousCallback.WorkerThread<GroupDetails, FetchGroupDetailsError> callback) {
SignalExecutors.UNBOUNDED.execute(() -> {
try {
callback.onComplete(getGroupDetails());
@ -43,6 +45,30 @@ final class GroupJoinRepository {
});
}
void joinGroup(@NonNull GroupDetails groupDetails,
@NonNull AsynchronousCallback.WorkerThread<JoinGroupSuccess, JoinGroupError> callback)
{
SignalExecutors.UNBOUNDED.execute(() -> {
try {
GroupManager.GroupActionResult groupActionResult = GroupManager.joinGroup(context,
groupInviteLinkUrl.getGroupMasterKey(),
groupInviteLinkUrl.getPassword(),
groupDetails.getJoinInfo(),
groupDetails.getAvatarBytes());
callback.onComplete(new JoinGroupSuccess(groupActionResult.getGroupRecipient(), groupActionResult.getThreadId()));
} catch (IOException e) {
callback.onError(JoinGroupError.NETWORK_ERROR);
} catch (GroupChangeBusyException e) {
callback.onError(JoinGroupError.BUSY);
} catch (GroupLinkNotActiveException e) {
callback.onError(JoinGroupError.GROUP_LINK_NOT_ACTIVE);
} catch (GroupChangeFailedException | MembershipNotSuitableForV2Exception e) {
callback.onError(JoinGroupError.FAILED);
}
});
}
@WorkerThread
private @NonNull GroupDetails getGroupDetails()
throws VerificationFailedException, IOException, GroupLinkNotActiveException
@ -52,13 +78,8 @@ final class GroupJoinRepository {
groupInviteLinkUrl.getPassword());
byte[] avatarBytes = tryGetAvatarBytes(joinInfo);
boolean requiresAdminApproval = joinInfo.getAddFromInviteLink() == AccessControl.AccessRequired.ADMINISTRATOR;
return new GroupDetails(joinInfo.getTitle(),
avatarBytes,
joinInfo.getMemberCount(),
requiresAdminApproval,
joinInfo.getRevision());
return new GroupDetails(joinInfo, avatarBytes);
}
private @Nullable byte[] tryGetAvatarBytes(@NonNull DecryptedGroupJoinInfo joinInfo) {
@ -69,9 +90,4 @@ final class GroupJoinRepository {
return null;
}
}
interface GetGroupDetailsCallback {
void onComplete(@NonNull GroupDetails groupDetails);
void onError(@NonNull FetchGroupDetailsError error);
}
}

View File

@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MediatorLiveData;
import androidx.lifecycle.MutableLiveData;
@ -10,35 +11,62 @@ import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl;
import org.thoughtcrime.securesms.util.AsynchronousCallback;
import org.thoughtcrime.securesms.util.SingleLiveEvent;
public class GroupJoinViewModel extends ViewModel {
private final GroupJoinRepository repository;
private final MutableLiveData<GroupDetails> groupDetails = new MutableLiveData<>();
private final MutableLiveData<FetchGroupDetailsError> errors = new SingleLiveEvent<>();
private final MutableLiveData<JoinGroupError> joinErrors = new SingleLiveEvent<>();
private final MutableLiveData<Boolean> busy = new MediatorLiveData<>();
private final MutableLiveData<JoinGroupSuccess> joinSuccess = new SingleLiveEvent<>();
private GroupJoinViewModel(@NonNull GroupJoinRepository repository) {
this.repository = repository;
busy.setValue(true);
repository.getGroupDetails(new GroupJoinRepository.GetGroupDetailsCallback() {
repository.getGroupDetails(new AsynchronousCallback.WorkerThread<GroupDetails, FetchGroupDetailsError>() {
@Override
public void onComplete(@NonNull GroupDetails details) {
public void onComplete(@Nullable GroupDetails details) {
busy.postValue(false);
groupDetails.postValue(details);
}
@Override
public void onError(@NonNull FetchGroupDetailsError error) {
public void onError(@Nullable FetchGroupDetailsError error) {
busy.postValue(false);
errors.postValue(error);
}
});
}
void join(@NonNull GroupDetails groupDetails) {
busy.setValue(true);
repository.joinGroup(groupDetails, new AsynchronousCallback.WorkerThread<JoinGroupSuccess, JoinGroupError>() {
@Override
public void onComplete(@Nullable JoinGroupSuccess result) {
busy.postValue(false);
joinSuccess.postValue(result);
}
@Override
public void onError(@Nullable JoinGroupError error) {
busy.postValue(false);
joinErrors.postValue(error);
}
});
}
LiveData<GroupDetails> getGroupDetails() {
return groupDetails;
}
LiveData<JoinGroupSuccess> getJoinSuccess() {
return joinSuccess;
}
LiveData<Boolean> isBusy() {
return busy;
}
@ -47,6 +75,10 @@ public class GroupJoinViewModel extends ViewModel {
return errors;
}
LiveData<JoinGroupError> getJoinErrors() {
return joinErrors;
}
public static class Factory implements ViewModelProvider.Factory {
private final Context context;

View File

@ -0,0 +1,8 @@
package org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining;
enum JoinGroupError {
BUSY,
GROUP_LINK_NOT_ACTIVE,
FAILED,
NETWORK_ERROR,
}

View File

@ -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;
}
}

View File

@ -1,25 +1,28 @@
package org.thoughtcrime.securesms.groups.v2.processing;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
import org.thoughtcrime.securesms.logging.Log;
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil;
import org.whispersystems.signalservice.api.groupsv2.GroupChangeReconstruct;
import org.whispersystems.signalservice.api.groupsv2.GroupChangeUtil;
import org.whispersystems.signalservice.api.groupsv2.NotAbleToApplyGroupV2ChangeException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.Objects;
import java.util.List;
final class GroupStateMapper {
private static final String TAG = Log.tag(GroupStateMapper.class);
static final int LATEST = Integer.MAX_VALUE;
static final int PLACEHOLDER_REVISION = -1;
private static final Comparator<ServerGroupLogEntry> BY_REVISION = (o1, o2) -> Integer.compare(o1.getRevision(), o2.getRevision());
@ -36,10 +39,18 @@ final class GroupStateMapper {
static @NonNull AdvanceGroupStateResult partiallyAdvanceGroupState(@NonNull GlobalGroupState inputState,
int maximumRevisionToApply)
{
ArrayList<LocalGroupLogEntry> appliedChanges = new ArrayList<>(inputState.getServerHistory().size());
AdvanceGroupStateResult groupStateResult = processChanges(inputState, maximumRevisionToApply);
return cleanDuplicatedChanges(groupStateResult, inputState.getLocalState());
}
private static @NonNull AdvanceGroupStateResult processChanges(@NonNull GlobalGroupState inputState,
int maximumRevisionToApply)
{
HashMap<Integer, ServerGroupLogEntry> statesToApplyNow = new HashMap<>(inputState.getServerHistory().size());
ArrayList<ServerGroupLogEntry> statesToApplyLater = new ArrayList<>(inputState.getServerHistory().size());
DecryptedGroup current = inputState.getLocalState();
StateChain<DecryptedGroup, DecryptedGroupChange> stateChain = createNewMapper();
if (inputState.getServerHistory().isEmpty()) {
return new AdvanceGroupStateResult(Collections.emptyList(), new GlobalGroupState(current, Collections.emptyList()));
@ -55,9 +66,15 @@ final class GroupStateMapper {
Collections.sort(statesToApplyLater, BY_REVISION);
final int from = inputState.getEarliestRevisionNumber();
final int from = Math.max(0, inputState.getEarliestRevisionNumber());
final int to = Math.min(inputState.getLatestRevisionNumber(), maximumRevisionToApply);
if (current != null && current.getRevision() == PLACEHOLDER_REVISION) {
Log.i(TAG, "Ignoring place holder group state");
} else {
stateChain.push(current, null);
}
for (int revision = from; revision >= 0 && revision <= to; revision++) {
ServerGroupLogEntry entry = statesToApplyNow.get(revision);
if (entry == null) {
@ -65,59 +82,64 @@ final class GroupStateMapper {
continue;
}
DecryptedGroup groupAtRevision = entry.getGroup();
DecryptedGroupChange changeAtRevision = entry.getChange();
if (stateChain.getLatestState() == null && entry.getGroup() != null && current != null && current.getRevision() == PLACEHOLDER_REVISION) {
DecryptedGroup previousState = DecryptedGroup.newBuilder(entry.getGroup())
.setTitle(current.getTitle())
.setAvatar(current.getAvatar())
.build();
if (current == null) {
Log.w(TAG, "No local state, accepting server state for V" + revision);
current = groupAtRevision;
if (groupAtRevision != null) {
appliedChanges.add(new LocalGroupLogEntry(groupAtRevision, changeAtRevision));
}
continue;
stateChain.push(previousState, null);
}
if (current.getRevision() + 1 != revision) {
Log.w(TAG, "Detected gap V" + revision);
stateChain.push(entry.getGroup(), entry.getChange());
}
if (changeAtRevision == null) {
Log.w(TAG, "Reconstructing change for V" + revision);
changeAtRevision = GroupChangeReconstruct.reconstructGroupChange(current, Objects.requireNonNull(groupAtRevision));
List<StateChain.Pair<DecryptedGroup, DecryptedGroupChange>> mapperList = stateChain.getList();
List<LocalGroupLogEntry> appliedChanges = new ArrayList<>(mapperList.size());
for (StateChain.Pair<DecryptedGroup, DecryptedGroupChange> entry : mapperList) {
if (current == null || entry.getDelta() != null) {
appliedChanges.add(new LocalGroupLogEntry(entry.getState(), entry.getDelta()));
}
}
DecryptedGroup groupWithChangeApplied;
return new AdvanceGroupStateResult(appliedChanges, new GlobalGroupState(stateChain.getLatestState(), statesToApplyLater));
}
private static AdvanceGroupStateResult cleanDuplicatedChanges(@NonNull AdvanceGroupStateResult groupStateResult,
@Nullable DecryptedGroup previousGroupState)
{
if (previousGroupState == null) return groupStateResult;
ArrayList<LocalGroupLogEntry> appliedChanges = new ArrayList<>(groupStateResult.getProcessedLogEntries().size());
for (LocalGroupLogEntry entry : groupStateResult.getProcessedLogEntries()) {
DecryptedGroupChange change = entry.getChange();
if (change != null) {
change = GroupChangeUtil.resolveConflict(previousGroupState, change).build();
}
appliedChanges.add(new LocalGroupLogEntry(entry.getGroup(), change));
previousGroupState = entry.getGroup();
}
return new AdvanceGroupStateResult(appliedChanges, groupStateResult.getNewGlobalGroupState());
}
private static StateChain<DecryptedGroup, DecryptedGroupChange> createNewMapper() {
return new StateChain<>(
(group, change) -> {
try {
groupWithChangeApplied = DecryptedGroupUtil.applyWithoutRevisionCheck(current, changeAtRevision);
return DecryptedGroupUtil.applyWithoutRevisionCheck(group, change);
} catch (NotAbleToApplyGroupV2ChangeException e) {
Log.w(TAG, "Unable to apply V" + revision, e);
continue;
Log.w(TAG, "Unable to apply V" + change.getRevision(), e);
return null;
}
if (groupAtRevision == null) {
Log.w(TAG, "Reconstructing state for V" + revision);
groupAtRevision = groupWithChangeApplied;
}
if (current.getRevision() != groupAtRevision.getRevision()) {
appliedChanges.add(new LocalGroupLogEntry(groupAtRevision, changeAtRevision));
} else {
DecryptedGroupChange sameRevisionDelta = GroupChangeReconstruct.reconstructGroupChange(current, groupAtRevision);
if (!DecryptedGroupUtil.changeIsEmpty(sameRevisionDelta)) {
appliedChanges.add(new LocalGroupLogEntry(groupAtRevision, sameRevisionDelta));
Log.w(TAG, "Inserted repair change for mismatch V" + revision);
}
}
DecryptedGroupChange missingChanges = GroupChangeReconstruct.reconstructGroupChange(groupWithChangeApplied, groupAtRevision);
if (!DecryptedGroupUtil.changeIsEmpty(missingChanges)) {
appliedChanges.add(new LocalGroupLogEntry(groupAtRevision, missingChanges));
Log.w(TAG, "Inserted repair change for gap V" + revision);
}
current = groupAtRevision;
}
return new AdvanceGroupStateResult(appliedChanges, new GlobalGroupState(current, statesToApplyLater));
},
(groupB, groupA) -> GroupChangeReconstruct.reconstructGroupChange(groupA, groupB),
(groupA, groupB) -> DecryptedGroupUtil.changeIsEmpty(GroupChangeReconstruct.reconstructGroupChange(groupA, groupB))
);
}
}

View File

@ -17,9 +17,7 @@ import org.signal.zkgroup.groups.GroupSecretParams;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.GroupDatabase;
import org.thoughtcrime.securesms.database.MessageDatabase;
import org.thoughtcrime.securesms.database.MmsDatabase;
import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.database.SmsDatabase;
import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.groups.GroupId;
@ -66,6 +64,7 @@ public final class GroupsV2StateProcessor {
private static final String TAG = Log.tag(GroupsV2StateProcessor.class);
public static final int LATEST = GroupStateMapper.LATEST;
public static final int PLACEHOLDER_REVISION = GroupStateMapper.PLACEHOLDER_REVISION;
private final Context context;
private final JobManager jobManager;
@ -177,10 +176,27 @@ public final class GroupsV2StateProcessor {
try {
inputGroupState = queryServer(localState, revision == LATEST && localState == null);
} catch (GroupNotAMemberException e) {
if (localState != null && signedGroupChange != null) {
try {
Log.i(TAG, "Applying P2P group change when not a member");
DecryptedGroup newState = DecryptedGroupUtil.applyWithoutRevisionCheck(localState, signedGroupChange);
inputGroupState = new GlobalGroupState(localState, Collections.singletonList(new ServerGroupLogEntry(newState, signedGroupChange)));
} catch (NotAbleToApplyGroupV2ChangeException failed) {
Log.w(TAG, "Unable to apply P2P group change when not a member", failed);
}
}
if (inputGroupState == null) {
if (localState != null && DecryptedGroupUtil.isPendingOrRequesting(localState, Recipient.self().getUuid().get())) {
Log.w(TAG, "Unable to query server for group " + groupId + " server says we're not in group, but we think we are a pending or requesting member");
} else {
Log.w(TAG, "Unable to query server for group " + groupId + " server says we're not in group, inserting leave message");
insertGroupLeave();
}
throw e;
}
}
} else {
Log.i(TAG, "Saved server query for group change");
}

View File

@ -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);
}
}

View File

@ -28,7 +28,6 @@ import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
import org.thoughtcrime.securesms.crypto.SecurityEvent;
import org.thoughtcrime.securesms.crypto.storage.TextSecureSessionStore;
import org.thoughtcrime.securesms.database.AttachmentDatabase;
import org.thoughtcrime.securesms.database.Database;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.GroupDatabase;
import org.thoughtcrime.securesms.database.GroupReceiptDatabase;
@ -36,10 +35,8 @@ import org.thoughtcrime.securesms.database.GroupReceiptDatabase.GroupReceiptInfo
import org.thoughtcrime.securesms.database.MessageDatabase;
import org.thoughtcrime.securesms.database.MessageDatabase.InsertResult;
import org.thoughtcrime.securesms.database.MessageDatabase.SyncMessageId;
import org.thoughtcrime.securesms.database.MmsDatabase;
import org.thoughtcrime.securesms.database.MmsSmsDatabase;
import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.database.SmsDatabase;
import org.thoughtcrime.securesms.database.StickerDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.database.model.Mention;
@ -350,16 +347,16 @@ public final class PushProcessMessageJob extends BaseJob {
if (isGv2Message) {
GroupMasterKey groupMasterKey = message.getGroupContext().get().getGroupV2().get().getMasterKey();
GroupId.V2 groupIdV2 = groupId.get().requireV2();
if (!groupV2PreProcessMessage(content, groupMasterKey, message.getGroupContext().get().getGroupV2().get())) {
Log.i(TAG, "Ignoring GV2 message for group we are not currently in " + groupId);
Log.i(TAG, "Ignoring GV2 message for group we are not currently in " + groupIdV2);
return;
}
GroupId.V2 groupIdV2 = groupId.get().requireV2();
Recipient sender = Recipient.externalPush(context, content.getSender());
if (!groupDatabase.isCurrentMember(groupIdV2, sender.getId())) {
Log.i(TAG, "Ignoring GV2 message from member not in group " + groupId);
Log.i(TAG, "Ignoring GV2 message from member not in group " + groupIdV2);
return;
}
}

View File

@ -7,7 +7,6 @@ import org.signal.zkgroup.groups.GroupMasterKey;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.GroupDatabase;
import org.thoughtcrime.securesms.database.MessageDatabase;
import org.thoughtcrime.securesms.database.MmsDatabase;
import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context;
import org.thoughtcrime.securesms.groups.GroupChangeBusyException;
import org.thoughtcrime.securesms.groups.GroupId;
@ -22,7 +21,6 @@ import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.MmsException;
import org.thoughtcrime.securesms.mms.OutgoingGroupUpdateMessage;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.Hex;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.groupsv2.NoCredentialForRedemptionTimeException;
@ -79,11 +77,12 @@ public final class WakeGroupV2Job extends BaseJob {
GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context);
GroupId.V2 groupId = GroupId.v2(groupMasterKey);
if (!groupDatabase.getGroup(groupId).isPresent()) {
if (groupDatabase.findGroup(groupId)) {
Log.w(TAG, "Group already exists " + groupId);
return;
} else {
GroupManager.updateGroupFromServer(context, groupMasterKey, GroupsV2StateProcessor.LATEST, System.currentTimeMillis(), null);
Log.i(TAG, "Group created " + groupId);
} else {
Log.w(TAG, "Group already exists " + groupId);
}
Optional<GroupDatabase.GroupRecord> group = groupDatabase.getGroup(groupId);

View File

@ -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));
}
};
}
}
}

View File

@ -112,9 +112,7 @@ public class CommunicationActions {
@Override
protected void onPostExecute(Long threadId) {
Intent intent = new Intent(context, ConversationActivity.class);
intent.putExtra(ConversationActivity.RECIPIENT_EXTRA, recipient.getId());
intent.putExtra(ConversationActivity.THREAD_ID_EXTRA, threadId);
Intent intent = ConversationActivity.buildIntent(context, recipient.getId(), threadId);
if (!TextUtils.isEmpty(text)) {
intent.putExtra(ConversationActivity.TEXT_EXTRA, text);

View File

@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.util;
import android.os.Handler;
import android.os.Looper;
/**
* A class that will throttle the number of runnables executed to be at most once every specified
@ -21,7 +22,7 @@ public class Debouncer {
* {@code threshold} milliseconds.
*/
public Debouncer(long threshold) {
this.handler = new Handler();
this.handler = new Handler(Looper.getMainLooper());
this.threshold = threshold;
}

View File

@ -99,6 +99,10 @@
layout="@layout/conversation_no_longer_a_member"
android:visibility="gone" />
<include
layout="@layout/conversation_requesting_bottom_banner"
android:visibility="gone" />
<include layout="@layout/conversation_input_panel" />
<include layout="@layout/conversation_search_nav" />

View File

@ -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>

View File

@ -245,6 +245,8 @@
<string name="ConversationActivity_unable_to_record_audio">Unable to record audio!</string>
<string name="ConversationActivity_you_cant_send_messages_to_this_group">You can\'t send messages to this group because you\'re no longer a member.</string>
<string name="ConversationActivity_there_is_no_app_available_to_handle_this_link_on_your_device">There is no app available to handle this link on your device.</string>
<string name="ConversationActivity_your_request_to_join_has_been_sent_to_the_group_admin">Your request to join has been sent to the group admin. You\'ll be notified when they take action.</string>
<string name="ConversationActivity_cancel_request">Cancel Request</string>
<string name="ConversationActivity_to_send_audio_messages_allow_signal_access_to_your_microphone">To send audio messages, allow Signal access to your microphone.</string>
<string name="ConversationActivity_signal_requires_the_microphone_permission_in_order_to_send_audio_messages">Signal requires the Microphone permission in order to send audio messages, but it has been permanently denied. Please continue to app settings, select \"Permissions\", and enable \"Microphone\".</string>
@ -657,7 +659,10 @@
<string name="GroupJoinBottomSheetDialogFragment_you_are_already_a_member">You are already a member</string>
<string name="GroupJoinBottomSheetDialogFragment_join">Join</string>
<string name="GroupJoinBottomSheetDialogFragment_request_to_join">Request to join</string>
<string name="GroupJoinBottomSheetDialogFragment_unable_to_join_group_please_try_again_later">Unable to join group. Please try again later</string>
<string name="GroupJoinBottomSheetDialogFragment_encountered_a_network_error">Encountered a network error.</string>
<string name="GroupJoinBottomSheetDialogFragment_this_group_link_is_not_active">This group link is not active</string>
<string name="GroupJoinBottomSheetDialogFragment_unable_to_get_group_information_please_try_again_later">Unable to get group information, please try again later</string>
<string name="GroupJoinBottomSheetDialogFragment_direct_join">Do you want to join this group and share your name and photo with its members?</string>
<string name="GroupJoinBottomSheetDialogFragment_admin_approval_needed">An admin of this group must approve your request before you can join this group. When you request to join, your name and photo will be shared with its members.</string>
@ -963,6 +968,7 @@
<!-- GV2 group link approvals -->
<string name="MessageRecord_s_approved_your_request_to_join_the_group">%1$s approved your request to join the group.</string>
<string name="MessageRecord_s_approved_a_request_to_join_the_group_from_s">%1$s approved a request to join the group from %2$s.</string>
<string name="MessageRecord_you_approved_a_request_to_join_the_group_from_s">You approved a request to join the group from %1$s.</string>
<string name="MessageRecord_your_request_to_join_the_group_has_been_approved">Your request to join the group has been approved.</string>
<string name="MessageRecord_a_request_to_join_the_group_from_s_has_been_approved">A request to join the group from %1$s has been approved.</string>
@ -970,6 +976,8 @@
<string name="MessageRecord_your_request_to_join_the_group_has_been_denied_by_an_admin">Your request to join the group has been denied by an admin.</string>
<string name="MessageRecord_s_denied_a_request_to_join_the_group_from_s">%1$s denied a request to join the group from %2$s.</string>
<string name="MessageRecord_a_request_to_join_the_group_from_s_has_been_denied">A request to join the group from %1$s has been denied.</string>
<string name="MessageRecord_you_canceled_your_request_to_join_the_group">You canceled your request to join the group.</string>
<string name="MessageRecord_s_canceled_their_request_to_join_the_group">%1$s canceled their request to join the group.</string>
<!-- End of GV2 specific update messages -->

View File

@ -1035,6 +1035,15 @@ public final class GroupsV2UpdateMessageProducerTest {
assertThat(describeChange(change), is(singletonList("Alice approved a request to join the group from Bob.")));
}
@Test
public void you_approved_another_join_request() {
DecryptedGroupChange change = changeBy(you)
.approveRequest(alice)
.build();
assertThat(describeChange(change), is(singletonList("You approved a request to join the group from Alice.")));
}
@Test
public void unknown_approved_your_join_request() {
DecryptedGroupChange change = changeByUnknown()
@ -1071,6 +1080,24 @@ public final class GroupsV2UpdateMessageProducerTest {
assertThat(describeChange(change), is(singletonList("Your request to join the group has been denied by an admin.")));
}
@Test
public void you_cancelled_your_join_request() {
DecryptedGroupChange change = changeBy(you)
.denyRequest(you)
.build();
assertThat(describeChange(change), is(singletonList("You canceled your request to join the group.")));
}
@Test
public void member_cancelled_their_join_request() {
DecryptedGroupChange change = changeBy(alice)
.denyRequest(alice)
.build();
assertThat(describeChange(change), is(singletonList("Alice canceled their request to join the group.")));
}
@Test
public void unknown_denied_your_join_request() {
DecryptedGroupChange change = changeByUnknown()

View File

@ -197,6 +197,10 @@ public final class GroupStateMapperTest {
public void known_group_three_states_to_update_update_latest_handle_gap_with_changes() {
DecryptedGroup currentState = state(0);
ServerGroupLogEntry log1 = serverLogEntry(1);
DecryptedGroup state3a = DecryptedGroup.newBuilder()
.setRevision(3)
.setTitle("Group Revision " + 3)
.build();
DecryptedGroup state3 = DecryptedGroup.newBuilder()
.setRevision(3)
.setTitle("Group Revision " + 3)
@ -213,7 +217,7 @@ public final class GroupStateMapperTest {
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, asList(log1, log3, log4)), LATEST);
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(asList(asLocal(log1),
asLocal(log3),
new LocalGroupLogEntry(state3a, log3.getChange()),
new LocalGroupLogEntry(state3, DecryptedGroupChange.newBuilder()
.setRevision(3)
.setNewAvatar(DecryptedString.newBuilder().setValue("Lost Avatar Update"))
@ -259,11 +263,16 @@ public final class GroupStateMapperTest {
DecryptedMember newMember = DecryptedMember.newBuilder()
.setUuid(UuidUtil.toByteString(UUID.randomUUID()))
.build();
ServerGroupLogEntry log8 = new ServerGroupLogEntry(DecryptedGroup.newBuilder()
DecryptedGroup state7b = DecryptedGroup.newBuilder()
.setRevision(8)
.addMembers(newMember)
.setTitle("Group Revision " + 8)
.build(),
.build();
DecryptedGroup state8 = DecryptedGroup.newBuilder()
.setRevision(8)
.setTitle("Group Revision " + 8)
.addMembers(newMember)
.build();
ServerGroupLogEntry log8 = new ServerGroupLogEntry(state8,
change(8) );
ServerGroupLogEntry log9 = new ServerGroupLogEntry(DecryptedGroup.newBuilder()
.setRevision(9)
@ -275,11 +284,11 @@ public final class GroupStateMapperTest {
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, asList(log7, log8, log9)), LATEST);
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(asList(asLocal(log7),
asLocal(log8),
asLocal(new ServerGroupLogEntry(log8.getGroup(), DecryptedGroupChange.newBuilder()
new LocalGroupLogEntry(state7b, log8.getChange()),
new LocalGroupLogEntry(state8, DecryptedGroupChange.newBuilder()
.setRevision(8)
.addNewMembers(newMember)
.build())),
.build()),
asLocal(log9))));
assertNewState(new GlobalGroupState(log9.getGroup(), emptyList()), advanceGroupStateResult.getNewGlobalGroupState());
assertEquals(log9.getGroup(), advanceGroupStateResult.getNewGlobalGroupState().getLocalState());
@ -298,18 +307,136 @@ public final class GroupStateMapperTest {
}
@Test
public void local_on_same_revision_but_incorrect_repair_necessary() {
public void no_repair_change_is_posted_if_the_local_state_is_a_placeholder() {
DecryptedGroup currentState = DecryptedGroup.newBuilder()
.setRevision(6)
.setRevision(GroupStateMapper.PLACEHOLDER_REVISION)
.setTitle("Incorrect group title, Revision " + 6)
.build();
ServerGroupLogEntry log6 = serverLogEntryWholeStateOnly(6);
ServerGroupLogEntry log6 = serverLogEntry(6);
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, singletonList(log6)), LATEST);
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(singletonList(localLogEntryNoEditor(6))));
assertNewState(new GlobalGroupState(state(6), emptyList()), advanceGroupStateResult.getNewGlobalGroupState());
assertEquals(state(6), advanceGroupStateResult.getNewGlobalGroupState().getLocalState());
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(singletonList(asLocal(log6))));
assertTrue(advanceGroupStateResult.getNewGlobalGroupState().getServerHistory().isEmpty());
assertEquals(log6.getGroup(), advanceGroupStateResult.getNewGlobalGroupState().getLocalState());
}
@Test
public void clears_changes_duplicated_in_the_placeholder() {
UUID newMemberUuid = UUID.randomUUID();
DecryptedMember newMember = DecryptedMember.newBuilder()
.setUuid(UuidUtil.toByteString(newMemberUuid))
.build();
DecryptedMember existingMember = DecryptedMember.newBuilder()
.setUuid(UuidUtil.toByteString(UUID.randomUUID()))
.build();
DecryptedGroup currentState = DecryptedGroup.newBuilder()
.setRevision(GroupStateMapper.PLACEHOLDER_REVISION)
.setTitle("Group Revision " + 8)
.addMembers(newMember)
.build();
ServerGroupLogEntry log8 = new ServerGroupLogEntry(DecryptedGroup.newBuilder()
.setRevision(8)
.addMembers(newMember)
.addMembers(existingMember)
.setTitle("Group Revision " + 8)
.build(),
DecryptedGroupChange.newBuilder()
.setRevision(8)
.setEditor(UuidUtil.toByteString(newMemberUuid))
.addNewMembers(newMember)
.build());
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, singletonList(log8)), LATEST);
assertNotNull(log8.getGroup());
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(emptyList()));
assertNewState(new GlobalGroupState(log8.getGroup(), emptyList()), advanceGroupStateResult.getNewGlobalGroupState());
assertEquals(log8.getGroup(), advanceGroupStateResult.getNewGlobalGroupState().getLocalState());
}
@Test
public void clears_changes_duplicated_in_a_non_placeholder() {
UUID editorUuid = UUID.randomUUID();
UUID newMemberUuid = UUID.randomUUID();
DecryptedMember newMember = DecryptedMember.newBuilder()
.setUuid(UuidUtil.toByteString(newMemberUuid))
.build();
DecryptedMember existingMember = DecryptedMember.newBuilder()
.setUuid(UuidUtil.toByteString(UUID.randomUUID()))
.build();
DecryptedGroup currentState = DecryptedGroup.newBuilder()
.setRevision(8)
.setTitle("Group Revision " + 8)
.addMembers(existingMember)
.build();
ServerGroupLogEntry log8 = new ServerGroupLogEntry(DecryptedGroup.newBuilder()
.setRevision(8)
.addMembers(existingMember)
.addMembers(newMember)
.setTitle("Group Revision " + 8)
.build(),
DecryptedGroupChange.newBuilder()
.setRevision(8)
.setEditor(UuidUtil.toByteString(editorUuid))
.addNewMembers(existingMember)
.addNewMembers(newMember)
.build());
DecryptedGroupChange expectedChange = DecryptedGroupChange.newBuilder()
.setRevision(8)
.setEditor(UuidUtil.toByteString(editorUuid))
.addNewMembers(newMember)
.build();
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, singletonList(log8)), LATEST);
assertNotNull(log8.getGroup());
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(singletonList(new LocalGroupLogEntry(log8.getGroup(), expectedChange))));
assertNewState(new GlobalGroupState(log8.getGroup(), emptyList()), advanceGroupStateResult.getNewGlobalGroupState());
assertEquals(log8.getGroup(), advanceGroupStateResult.getNewGlobalGroupState().getLocalState());
}
@Test
public void notices_changes_in_avatar_and_title_but_not_members_in_placeholder() {
UUID newMemberUuid = UUID.randomUUID();
DecryptedMember newMember = DecryptedMember.newBuilder()
.setUuid(UuidUtil.toByteString(newMemberUuid))
.build();
DecryptedMember existingMember = DecryptedMember.newBuilder()
.setUuid(UuidUtil.toByteString(UUID.randomUUID()))
.build();
DecryptedGroup currentState = DecryptedGroup.newBuilder()
.setRevision(GroupStateMapper.PLACEHOLDER_REVISION)
.setTitle("Incorrect group title")
.setAvatar("Incorrect group avatar")
.addMembers(newMember)
.build();
ServerGroupLogEntry log8 = new ServerGroupLogEntry(DecryptedGroup.newBuilder()
.setRevision(8)
.addMembers(newMember)
.addMembers(existingMember)
.setTitle("Group Revision " + 8)
.setAvatar("Group Avatar " + 8)
.build(),
DecryptedGroupChange.newBuilder()
.setRevision(8)
.setEditor(UuidUtil.toByteString(newMemberUuid))
.addNewMembers(newMember)
.build());
DecryptedGroupChange expectedChange = DecryptedGroupChange.newBuilder()
.setRevision(8)
.setNewTitle(DecryptedString.newBuilder().setValue("Group Revision " + 8))
.setNewAvatar(DecryptedString.newBuilder().setValue("Group Avatar " + 8))
.build();
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, singletonList(log8)), LATEST);
assertNotNull(log8.getGroup());
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(singletonList(new LocalGroupLogEntry(log8.getGroup(), expectedChange))));
assertNewState(new GlobalGroupState(log8.getGroup(), emptyList()), advanceGroupStateResult.getNewGlobalGroupState());
assertEquals(log8.getGroup(), advanceGroupStateResult.getNewGlobalGroupState().getLocalState());
}
private static void assertNewState(GlobalGroupState expected, GlobalGroupState actual) {

View File

@ -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);
}
}

View File

@ -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);
}

View File

@ -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;
}
}

View File

@ -186,6 +186,23 @@ public final class DecryptedGroupUtil {
return -1;
}
public static Optional<DecryptedRequestingMember> findRequestingByUuid(Collection<DecryptedRequestingMember> members, UUID uuid) {
ByteString uuidBytes = UuidUtil.toByteString(uuid);
for (DecryptedRequestingMember member : members) {
if (uuidBytes.equals(member.getUuid())) {
return Optional.of(member);
}
}
return Optional.absent();
}
public static boolean isPendingOrRequesting(DecryptedGroup group, UUID uuid) {
return findPendingByUuid(group.getPendingMembersList(), uuid).isPresent() ||
findRequestingByUuid(group.getRequestingMembersList(), uuid).isPresent();
}
/**
* Removes the uuid from the full members of a group.
* <p>

View File

@ -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);
}
}

View File

@ -65,6 +65,42 @@ public final class GroupChangeUtil {
GroupChange.Actions encryptedChange)
{
GroupChange.Actions.Builder result = GroupChange.Actions.newBuilder(encryptedChange);
resolveConflict(groupState, conflictingChange, new GroupChangeActionsBuilderChangeSetModifier(result));
return result;
}
/**
* Given the latest group state and a conflicting change, decides which changes to carry forward
* and returns a new group change which could be empty.
* <p>
* Titles, avatars, and other settings are carried forward if they are different. Last writer wins.
* <p>
* Membership additions and removals also respect last writer wins and are removed if they have
* already been applied. e.g. you add someone but they are already added.
* <p>
* Membership additions will be altered to {@link DecryptedGroupChange} promotes if someone has
* invited them since.
*
* @param groupState Latest group state in plaintext.
* @param conflictingChange The potentially conflicting change in plaintext.
* @return A new change builder.
*/
public static DecryptedGroupChange.Builder resolveConflict(DecryptedGroup groupState,
DecryptedGroupChange conflictingChange)
{
DecryptedGroupChange.Builder result = DecryptedGroupChange.newBuilder(conflictingChange);
resolveConflict(groupState, conflictingChange, new DecryptedGroupChangeActionsBuilderChangeSetModifier(result));
return result;
}
private static void resolveConflict(DecryptedGroup groupState,
DecryptedGroupChange conflictingChange,
ChangeSetModifier changeSetModifier)
{
HashMap<ByteString, DecryptedMember> fullMembersByUuid = new HashMap<>(groupState.getMembersCount());
HashMap<ByteString, DecryptedPendingMember> pendingMembersByUuid = new HashMap<>(groupState.getPendingMembersCount());
HashMap<ByteString, DecryptedRequestingMember> requestingMembersByUuid = new HashMap<>(groupState.getMembersCount());
@ -81,27 +117,25 @@ public final class GroupChangeUtil {
requestingMembersByUuid.put(member.getUuid(), member);
}
resolveField3AddMembers (conflictingChange, result, fullMembersByUuid, pendingMembersByUuid);
resolveField4DeleteMembers (conflictingChange, result, fullMembersByUuid);
resolveField5ModifyMemberRoles (conflictingChange, result, fullMembersByUuid);
resolveField6ModifyProfileKeys (conflictingChange, result, fullMembersByUuid);
resolveField7AddPendingMembers (conflictingChange, result, fullMembersByUuid, pendingMembersByUuid);
resolveField8DeletePendingMembers (conflictingChange, result, pendingMembersByUuid);
resolveField9PromotePendingMembers (conflictingChange, result, pendingMembersByUuid);
resolveField10ModifyTitle (groupState, conflictingChange, result);
resolveField11ModifyAvatar (groupState, conflictingChange, result);
resolveField12modifyDisappearingMessagesTimer(groupState, conflictingChange, result);
resolveField13modifyAttributesAccess (groupState, conflictingChange, result);
resolveField14modifyAttributesAccess (groupState, conflictingChange, result);
resolveField15modifyAddFromInviteLinkAccess (groupState, conflictingChange, result);
resolveField16AddRequestingMembers (conflictingChange, result, fullMembersByUuid, pendingMembersByUuid);
resolveField17DeleteMembers (conflictingChange, result, requestingMembersByUuid);
resolveField18PromoteRequestingMembers (conflictingChange, result, requestingMembersByUuid);
return result;
resolveField3AddMembers (conflictingChange, changeSetModifier, fullMembersByUuid, pendingMembersByUuid);
resolveField4DeleteMembers (conflictingChange, changeSetModifier, fullMembersByUuid);
resolveField5ModifyMemberRoles (conflictingChange, changeSetModifier, fullMembersByUuid);
resolveField6ModifyProfileKeys (conflictingChange, changeSetModifier, fullMembersByUuid);
resolveField7AddPendingMembers (conflictingChange, changeSetModifier, fullMembersByUuid, pendingMembersByUuid);
resolveField8DeletePendingMembers (conflictingChange, changeSetModifier, pendingMembersByUuid);
resolveField9PromotePendingMembers (conflictingChange, changeSetModifier, pendingMembersByUuid);
resolveField10ModifyTitle (groupState, conflictingChange, changeSetModifier);
resolveField11ModifyAvatar (groupState, conflictingChange, changeSetModifier);
resolveField12modifyDisappearingMessagesTimer(groupState, conflictingChange, changeSetModifier);
resolveField13modifyAttributesAccess (groupState, conflictingChange, changeSetModifier);
resolveField14modifyAttributesAccess (groupState, conflictingChange, changeSetModifier);
resolveField15modifyAddFromInviteLinkAccess (groupState, conflictingChange, changeSetModifier);
resolveField16AddRequestingMembers (conflictingChange, changeSetModifier, fullMembersByUuid, pendingMembersByUuid);
resolveField17DeleteMembers (conflictingChange, changeSetModifier, requestingMembersByUuid);
resolveField18PromoteRequestingMembers (conflictingChange, changeSetModifier, requestingMembersByUuid);
}
private static void resolveField3AddMembers(DecryptedGroupChange conflictingChange, GroupChange.Actions.Builder result, HashMap<ByteString, DecryptedMember> fullMembersByUuid, HashMap<ByteString, DecryptedPendingMember> pendingMembersByUuid) {
private static void resolveField3AddMembers(DecryptedGroupChange conflictingChange, ChangeSetModifier result, HashMap<ByteString, DecryptedMember> fullMembersByUuid, HashMap<ByteString, DecryptedPendingMember> pendingMembersByUuid) {
List<DecryptedMember> newMembersList = conflictingChange.getNewMembersList();
for (int i = newMembersList.size() - 1; i >= 0; i--) {
@ -110,14 +144,12 @@ public final class GroupChangeUtil {
if (fullMembersByUuid.containsKey(member.getUuid())) {
result.removeAddMembers(i);
} else if (pendingMembersByUuid.containsKey(member.getUuid())) {
GroupChange.Actions.AddMemberAction addMemberAction = result.getAddMembersList().get(i);
result.removeAddMembers(i);
result.addPromotePendingMembers(GroupChange.Actions.PromotePendingMemberAction.newBuilder().setPresentation(addMemberAction.getAdded().getPresentation()));
result.moveAddToPromote(i);
}
}
}
private static void resolveField4DeleteMembers(DecryptedGroupChange conflictingChange, GroupChange.Actions.Builder result, HashMap<ByteString, DecryptedMember> fullMembersByUuid) {
private static void resolveField4DeleteMembers(DecryptedGroupChange conflictingChange, ChangeSetModifier result, HashMap<ByteString, DecryptedMember> fullMembersByUuid) {
List<ByteString> deletedMembersList = conflictingChange.getDeleteMembersList();
for (int i = deletedMembersList.size() - 1; i >= 0; i--) {
@ -129,7 +161,7 @@ public final class GroupChangeUtil {
}
}
private static void resolveField5ModifyMemberRoles(DecryptedGroupChange conflictingChange, GroupChange.Actions.Builder result, HashMap<ByteString, DecryptedMember> fullMembersByUuid) {
private static void resolveField5ModifyMemberRoles(DecryptedGroupChange conflictingChange, ChangeSetModifier result, HashMap<ByteString, DecryptedMember> fullMembersByUuid) {
List<DecryptedModifyMemberRole> modifyRolesList = conflictingChange.getModifyMemberRolesList();
for (int i = modifyRolesList.size() - 1; i >= 0; i--) {
@ -142,7 +174,7 @@ public final class GroupChangeUtil {
}
}
private static void resolveField6ModifyProfileKeys(DecryptedGroupChange conflictingChange, GroupChange.Actions.Builder result, HashMap<ByteString, DecryptedMember> fullMembersByUuid) {
private static void resolveField6ModifyProfileKeys(DecryptedGroupChange conflictingChange, ChangeSetModifier result, HashMap<ByteString, DecryptedMember> fullMembersByUuid) {
List<DecryptedMember> modifyProfileKeysList = conflictingChange.getModifiedProfileKeysList();
for (int i = modifyProfileKeysList.size() - 1; i >= 0; i--) {
@ -155,7 +187,7 @@ public final class GroupChangeUtil {
}
}
private static void resolveField7AddPendingMembers(DecryptedGroupChange conflictingChange, GroupChange.Actions.Builder result, HashMap<ByteString, DecryptedMember> fullMembersByUuid, HashMap<ByteString, DecryptedPendingMember> pendingMembersByUuid) {
private static void resolveField7AddPendingMembers(DecryptedGroupChange conflictingChange, ChangeSetModifier result, HashMap<ByteString, DecryptedMember> fullMembersByUuid, HashMap<ByteString, DecryptedPendingMember> pendingMembersByUuid) {
List<DecryptedPendingMember> newPendingMembersList = conflictingChange.getNewPendingMembersList();
for (int i = newPendingMembersList.size() - 1; i >= 0; i--) {
@ -167,7 +199,7 @@ public final class GroupChangeUtil {
}
}
private static void resolveField8DeletePendingMembers(DecryptedGroupChange conflictingChange, GroupChange.Actions.Builder result, HashMap<ByteString, DecryptedPendingMember> pendingMembersByUuid) {
private static void resolveField8DeletePendingMembers(DecryptedGroupChange conflictingChange, ChangeSetModifier result, HashMap<ByteString, DecryptedPendingMember> pendingMembersByUuid) {
List<DecryptedPendingMemberRemoval> deletePendingMembersList = conflictingChange.getDeletePendingMembersList();
for (int i = deletePendingMembersList.size() - 1; i >= 0; i--) {
@ -179,7 +211,7 @@ public final class GroupChangeUtil {
}
}
private static void resolveField9PromotePendingMembers(DecryptedGroupChange conflictingChange, GroupChange.Actions.Builder result, HashMap<ByteString, DecryptedPendingMember> pendingMembersByUuid) {
private static void resolveField9PromotePendingMembers(DecryptedGroupChange conflictingChange, ChangeSetModifier result, HashMap<ByteString, DecryptedPendingMember> pendingMembersByUuid) {
List<DecryptedMember> promotePendingMembersList = conflictingChange.getPromotePendingMembersList();
for (int i = promotePendingMembersList.size() - 1; i >= 0; i--) {
@ -191,43 +223,43 @@ public final class GroupChangeUtil {
}
}
private static void resolveField10ModifyTitle(DecryptedGroup groupState, DecryptedGroupChange conflictingChange, GroupChange.Actions.Builder result) {
private static void resolveField10ModifyTitle(DecryptedGroup groupState, DecryptedGroupChange conflictingChange, ChangeSetModifier result) {
if (conflictingChange.hasNewTitle() && conflictingChange.getNewTitle().getValue().equals(groupState.getTitle())) {
result.clearModifyTitle();
}
}
private static void resolveField11ModifyAvatar(DecryptedGroup groupState, DecryptedGroupChange conflictingChange, GroupChange.Actions.Builder result) {
private static void resolveField11ModifyAvatar(DecryptedGroup groupState, DecryptedGroupChange conflictingChange, ChangeSetModifier result) {
if (conflictingChange.hasNewAvatar() && conflictingChange.getNewAvatar().getValue().equals(groupState.getAvatar())) {
result.clearModifyAvatar();
}
}
private static void resolveField12modifyDisappearingMessagesTimer(DecryptedGroup groupState, DecryptedGroupChange conflictingChange, GroupChange.Actions.Builder result) {
private static void resolveField12modifyDisappearingMessagesTimer(DecryptedGroup groupState, DecryptedGroupChange conflictingChange, ChangeSetModifier result) {
if (conflictingChange.hasNewTimer() && conflictingChange.getNewTimer().getDuration() == groupState.getDisappearingMessagesTimer().getDuration()) {
result.clearModifyDisappearingMessagesTimer();
}
}
private static void resolveField13modifyAttributesAccess(DecryptedGroup groupState, DecryptedGroupChange conflictingChange, GroupChange.Actions.Builder result) {
private static void resolveField13modifyAttributesAccess(DecryptedGroup groupState, DecryptedGroupChange conflictingChange, ChangeSetModifier result) {
if (conflictingChange.getNewAttributeAccess() == groupState.getAccessControl().getAttributes()) {
result.clearModifyAttributesAccess();
}
}
private static void resolveField14modifyAttributesAccess(DecryptedGroup groupState, DecryptedGroupChange conflictingChange, GroupChange.Actions.Builder result) {
private static void resolveField14modifyAttributesAccess(DecryptedGroup groupState, DecryptedGroupChange conflictingChange, ChangeSetModifier result) {
if (conflictingChange.getNewMemberAccess() == groupState.getAccessControl().getMembers()) {
result.clearModifyMemberAccess();
}
}
private static void resolveField15modifyAddFromInviteLinkAccess(DecryptedGroup groupState, DecryptedGroupChange conflictingChange, GroupChange.Actions.Builder result) {
private static void resolveField15modifyAddFromInviteLinkAccess(DecryptedGroup groupState, DecryptedGroupChange conflictingChange, ChangeSetModifier result) {
if (conflictingChange.getNewInviteLinkAccess() == groupState.getAccessControl().getAddFromInviteLink()) {
result.clearModifyAddFromInviteLinkAccess();
}
}
private static void resolveField16AddRequestingMembers(DecryptedGroupChange conflictingChange, GroupChange.Actions.Builder result, HashMap<ByteString, DecryptedMember> fullMembersByUuid, HashMap<ByteString, DecryptedPendingMember> pendingMembersByUuid) {
private static void resolveField16AddRequestingMembers(DecryptedGroupChange conflictingChange, ChangeSetModifier result, HashMap<ByteString, DecryptedMember> fullMembersByUuid, HashMap<ByteString, DecryptedPendingMember> pendingMembersByUuid) {
List<DecryptedRequestingMember> newMembersList = conflictingChange.getNewRequestingMembersList();
for (int i = newMembersList.size() - 1; i >= 0; i--) {
@ -236,15 +268,13 @@ public final class GroupChangeUtil {
if (fullMembersByUuid.containsKey(member.getUuid())) {
result.removeAddRequestingMembers(i);
} else if (pendingMembersByUuid.containsKey(member.getUuid())) {
GroupChange.Actions.AddRequestingMemberAction addMemberAction = result.getAddRequestingMembersList().get(i);
result.removeAddRequestingMembers(i);
result.addPromotePendingMembers(0, GroupChange.Actions.PromotePendingMemberAction.newBuilder().setPresentation(addMemberAction.getAdded().getPresentation()));
result.moveAddRequestingMembersToPromote(i);
}
}
}
private static void resolveField17DeleteMembers(DecryptedGroupChange conflictingChange,
GroupChange.Actions.Builder result,
ChangeSetModifier result,
HashMap<ByteString, DecryptedRequestingMember> requestingMembers)
{
List<ByteString> deletedMembersList = conflictingChange.getDeleteRequestingMembersList();
@ -259,7 +289,7 @@ public final class GroupChangeUtil {
}
private static void resolveField18PromoteRequestingMembers(DecryptedGroupChange conflictingChange,
GroupChange.Actions.Builder result,
ChangeSetModifier result,
HashMap<ByteString, DecryptedRequestingMember> requestingMembersByUuid)
{
List<DecryptedApproveMember> promoteRequestingMembersList = conflictingChange.getPromoteRequestingMembersList();

View File

@ -7,6 +7,10 @@ package org.whispersystems.signalservice.api.groupsv2;
* - the master key does not match a group on the server
*/
public final class GroupLinkNotActiveException extends Exception {
GroupLinkNotActiveException() {
public GroupLinkNotActiveException() {
}
public GroupLinkNotActiveException(Throwable t) {
super(t);
}
}

View File

@ -114,7 +114,7 @@ public final class GroupsV2Api {
}
public DecryptedGroupJoinInfo getGroupJoinInfo(GroupSecretParams groupSecretParams,
byte[] password,
Optional<byte[]> password,
GroupsV2AuthorizationString authorization)
throws IOException, GroupLinkNotActiveException
{
@ -148,10 +148,11 @@ public final class GroupsV2Api {
}
public GroupChange patchGroup(GroupChange.Actions groupChange,
GroupsV2AuthorizationString authorization)
GroupsV2AuthorizationString authorization,
Optional<byte[]> groupLinkPassword)
throws IOException
{
return socket.patchGroupsV2Group(groupChange, authorization.toString());
return socket.patchGroupsV2Group(groupChange, authorization.toString(), groupLinkPassword);
}
private static HashMap<Integer, AuthCredentialResponse> parseCredentialResponse(CredentialResponse credentialResponse)

View File

@ -188,8 +188,7 @@ public final class GroupsV2Operations {
actions.addAddMembers(GroupChange.Actions.AddMemberAction
.newBuilder()
.setAdded(groupOperations.member(profileKeyCredential, Member.Role.DEFAULT))
.setJoinFromInviteLink(true));
.setAdded(groupOperations.member(profileKeyCredential, Member.Role.DEFAULT)));
return actions;
}
@ -574,6 +573,7 @@ public final class GroupsV2Operations {
.setMemberCount(joinInfo.getMemberCount())
.setAddFromInviteLink(joinInfo.getAddFromInviteLink())
.setRevision(joinInfo.getRevision())
.setPendingAdminApproval(joinInfo.getPendingAdminApproval())
.build();
}

View File

@ -1957,11 +1957,14 @@ public class PushServiceSocket {
return AvatarUploadAttributes.parseFrom(readBodyBytes(response));
}
public GroupChange patchGroupsV2Group(GroupChange.Actions groupChange, String authorization)
public GroupChange patchGroupsV2Group(GroupChange.Actions groupChange, String authorization, Optional<byte[]> groupLinkPassword)
throws NonSuccessfulResponseCodeException, PushNetworkException, InvalidProtocolBufferException
{
String path = groupLinkPassword.transform(p -> String.format(GROUPSV2_GROUP_PASSWORD, Base64UrlSafe.encodeBytesWithoutPadding(p)))
.or(GROUPSV2_GROUP);
ResponseBody response = makeStorageRequest(authorization,
GROUPSV2_GROUP,
path,
"PATCH",
protobufRequestBody(groupChange),
GROUPS_V2_PATCH_RESPONSE_HANDLER);
@ -1981,11 +1984,12 @@ public class PushServiceSocket {
return GroupChanges.parseFrom(readBodyBytes(response));
}
public GroupJoinInfo getGroupJoinInfo(byte[] groupLinkPassword, GroupsV2AuthorizationString authorization)
public GroupJoinInfo getGroupJoinInfo(Optional<byte[]> groupLinkPassword, GroupsV2AuthorizationString authorization)
throws NonSuccessfulResponseCodeException, PushNetworkException, InvalidProtocolBufferException
{
String passwordParam = groupLinkPassword.transform(Base64UrlSafe::encodeBytesWithoutPadding).or("");
ResponseBody response = makeStorageRequest(authorization.toString(),
String.format(GROUPSV2_GROUP_JOIN, Base64UrlSafe.encodeBytesWithoutPadding(groupLinkPassword)),
String.format(GROUPSV2_GROUP_JOIN, passwordParam),
"GET",
null,
GROUPS_V2_GET_JOIN_INFO_HANDLER);

View File

@ -100,4 +100,5 @@ message DecryptedGroupJoinInfo {
uint32 memberCount = 4;
AccessControl.AccessRequired addFromInviteLink = 5;
uint32 revision = 6;
bool pendingAdminApproval = 7;
}

View File

@ -208,4 +208,5 @@ message GroupJoinInfo {
uint32 memberCount = 4;
AccessControl.AccessRequired addFromInviteLink = 5;
uint32 revision = 6;
bool pendingAdminApproval = 7;
}

View File

@ -39,7 +39,7 @@ public final class GroupChangeUtil_resolveConflict_Test {
/**
* Reflects over the generated protobuf class and ensures that no new fields have been added since we wrote this.
* <p>
* If we didn't, newly added fields would not be resolved by {@link GroupChangeUtil#resolveConflict}.
* If we didn't, newly added fields would not be resolved by {@link GroupChangeUtil#resolveConflict(DecryptedGroup, DecryptedGroupChange, GroupChange.Actions)}.
*/
@Test
public void ensure_resolveConflict_knows_about_all_fields_of_DecryptedGroupChange() {
@ -52,7 +52,7 @@ public final class GroupChangeUtil_resolveConflict_Test {
/**
* Reflects over the generated protobuf class and ensures that no new fields have been added since we wrote this.
* <p>
* If we didn't, newly added fields would not be resolved by {@link GroupChangeUtil#resolveConflict}.
* If we didn't, newly added fields would not be resolved by {@link GroupChangeUtil#resolveConflict(DecryptedGroup, DecryptedGroupChange, GroupChange.Actions)}.
*/
@Test
public void ensure_resolveConflict_knows_about_all_fields_of_GroupChange() {
@ -65,7 +65,7 @@ public final class GroupChangeUtil_resolveConflict_Test {
/**
* Reflects over the generated protobuf class and ensures that no new fields have been added since we wrote this.
* <p>
* If we didn't, newly added fields would not be resolved by {@link GroupChangeUtil#resolveConflict}.
* If we didn't, newly added fields would not be resolved by {@link GroupChangeUtil#resolveConflict(DecryptedGroup, DecryptedGroupChange, GroupChange.Actions)}.
*/
@Test
public void ensure_resolveConflict_knows_about_all_fields_of_DecryptedGroup() {

View File

@ -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);
}
}

View File

@ -12,6 +12,8 @@ import org.whispersystems.signalservice.internal.util.Util;
import org.whispersystems.signalservice.testutil.ZkGroupLibraryUtil;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.whispersystems.signalservice.api.groupsv2.ProtobufTestUtils.getMaxDeclaredFieldNumber;
public final class GroupsV2Operations_decrypt_groupJoinInfo_Test {
@ -39,7 +41,7 @@ public final class GroupsV2Operations_decrypt_groupJoinInfo_Test {
int maxFieldFound = getMaxDeclaredFieldNumber(GroupJoinInfo.class);
assertEquals("GroupOperations and its tests need updating to account for new fields on " + GroupJoinInfo.class.getName(),
6, maxFieldFound);
7, maxFieldFound);
}
@Test
@ -107,4 +109,26 @@ public final class GroupsV2Operations_decrypt_groupJoinInfo_Test {
assertEquals(11, decryptedGroupJoinInfo.getRevision());
}
@Test
public void pending_approval_passed_though_7_true() {
GroupJoinInfo groupJoinInfo = GroupJoinInfo.newBuilder()
.setPendingAdminApproval(true)
.build();
DecryptedGroupJoinInfo decryptedGroupJoinInfo = groupOperations.decryptGroupJoinInfo(groupJoinInfo);
assertTrue(decryptedGroupJoinInfo.getPendingAdminApproval());
}
@Test
public void pending_approval_passed_though_7_false() {
GroupJoinInfo groupJoinInfo = GroupJoinInfo.newBuilder()
.setPendingAdminApproval(false)
.build();
DecryptedGroupJoinInfo decryptedGroupJoinInfo = groupOperations.decryptGroupJoinInfo(groupJoinInfo);
assertFalse(decryptedGroupJoinInfo.getPendingAdminApproval());
}
}