From 860f06ec9e536d24a3e65309b61c8b31e76ea9d4 Mon Sep 17 00:00:00 2001 From: Alan Evans Date: Wed, 26 Aug 2020 12:51:25 -0300 Subject: [PATCH] Join group via invite link. --- .../conversation/ConversationActivity.java | 86 ++- .../conversation/ConversationBannerView.java | 2 + .../conversation/ConversationFragment.java | 6 +- .../ConversationGroupViewModel.java | 56 +- .../securesms/database/GroupDatabase.java | 35 +- .../model/GroupsV2UpdateMessageProducer.java | 24 +- .../database/model/MessageRecord.java | 2 +- .../GroupJoinAlreadyAMemberException.java | 10 + .../securesms/groups/GroupManager.java | 36 +- .../securesms/groups/GroupManagerV2.java | 442 +++++++++++++- .../securesms/groups/ui/GroupErrors.java | 8 +- .../joining/GroupDetails.java | 45 +- .../GroupJoinBottomSheetDialogFragment.java | 57 +- .../joining/GroupJoinRepository.java | 46 +- .../joining/GroupJoinViewModel.java | 38 +- .../joining/JoinGroupError.java | 8 + .../joining/JoinGroupSuccess.java | 21 + .../v2/processing/GroupStateMapper.java | 134 +++-- .../v2/processing/GroupsV2StateProcessor.java | 28 +- .../groups/v2/processing/StateChain.java | 172 ++++++ .../securesms/jobs/PushProcessMessageJob.java | 11 +- .../securesms/jobs/WakeGroupV2Job.java | 9 +- .../securesms/util/AsynchronousCallback.java | 64 ++ .../securesms/util/CommunicationActions.java | 4 +- .../securesms/util/Debouncer.java | 3 +- .../main/res/layout/conversation_activity.xml | 4 + .../conversation_requesting_bottom_banner.xml | 28 + app/src/main/res/values/strings.xml | 8 + .../GroupsV2UpdateMessageProducerTest.java | 27 + .../v2/processing/GroupStateMapperTest.java | 167 +++++- .../groups/v2/processing/StateChainTest.java | 133 +++++ .../api/groupsv2/ChangeSetModifier.java | 39 ++ ...ChangeActionsBuilderChangeSetModifier.java | 133 +++++ .../api/groupsv2/DecryptedGroupUtil.java | 17 + ...ChangeActionsBuilderChangeSetModifier.java | 105 ++++ .../api/groupsv2/GroupChangeUtil.java | 112 ++-- .../groupsv2/GroupLinkNotActiveException.java | 6 +- .../api/groupsv2/GroupsV2Api.java | 7 +- .../api/groupsv2/GroupsV2Operations.java | 4 +- .../internal/push/PushServiceSocket.java | 20 +- .../src/main/proto/DecryptedGroups.proto | 11 +- libsignal/service/src/main/proto/Groups.proto | 13 +- .../GroupChangeUtil_resolveConflict_Test.java | 6 +- ...il_resolveConflict_decryptedOnly_Test.java | 546 ++++++++++++++++++ ...Operations_decrypt_groupJoinInfo_Test.java | 26 +- 45 files changed, 2488 insertions(+), 271 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/GroupJoinAlreadyAMemberException.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/JoinGroupError.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/JoinGroupSuccess.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/StateChain.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/util/AsynchronousCallback.java create mode 100644 app/src/main/res/layout/conversation_requesting_bottom_banner.xml create mode 100644 app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/StateChainTest.java create mode 100644 libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/ChangeSetModifier.java create mode 100644 libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupChangeActionsBuilderChangeSetModifier.java create mode 100644 libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeActionsBuilderChangeSetModifier.java create mode 100644 libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil_resolveConflict_decryptedOnly_Test.java diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java index 91f4f97b90..982dfa79a4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java @@ -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 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() { + @Override + public void onComplete(@Nullable Void result) { + Log.d(TAG, "Cancel request complete"); + } + + @Override + public void onError(@Nullable GroupChangeFailureReason error) { + Log.d(TAG, "Cancel join request failed " + error); + Toast.makeText(ConversationActivity.this, GroupErrors.getUserDisplayMessage(error), Toast.LENGTH_SHORT).show(); + } + }.toWorkerCallback())); + } + + inputPanel.setVisibility(canSendMessages ? View.VISIBLE : View.GONE); + inputPanel.setEnabled(canSendMessages); + sendButton.setEnabled(canSendMessages); + attachButton.setEnabled(canSendMessages); }); } @@ -1754,7 +1816,9 @@ public class ConversationActivity extends PassphraseRequiredActivity ImageButton quickCameraToggle = ViewUtil.findById(this, R.id.quick_camera_toggle); ImageButton inlineAttachmentButton = ViewUtil.findById(this, R.id.inline_attachment_button); - noLongerMemberBanner = findViewById(R.id.conversation_no_longer_member_banner); + noLongerMemberBanner = findViewById(R.id.conversation_no_longer_member_banner); + requestingMemberBanner = findViewById(R.id.conversation_requesting_banner); + cancelJoinRequest = findViewById(R.id.conversation_cancel_request); container.addOnKeyboardShownListener(this); inputPanel.setListener(this); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationBannerView.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationBannerView.java index 328a5d08ba..3f63840908 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationBannerView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationBannerView.java @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java index 97f583989f..69494e2162 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -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)); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationGroupViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationGroupViewModel.java index 8f5f185aaa..18cc7d1799 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationGroupViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationGroupViewModel.java @@ -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; - private final MutableLiveData liveRecipient; - private final LiveData groupActiveState; +final class ConversationGroupViewModel extends ViewModel { + + private final MutableLiveData liveRecipient; + private final LiveData groupActiveState; + private final LiveData selfMembershipLevel; private ConversationGroupViewModel() { - liveRecipient = new MutableLiveData<>(); - LiveData groupRecord = LiveDataUtil.mapAsync(liveRecipient, this::getGroupRecordForRecipient); - groupActiveState = Transformations.distinctUntilChanged(Transformations.map(groupRecord, this::mapToGroupActiveState)); + this.liveRecipient = new MutableLiveData<>(); + + LiveData 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 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 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; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java index 85ed4a244a..975bfa714a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java @@ -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 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()); - contentValues.put(ACTIVE, 1); + + if (groupId.isV2()) { + contentValues.put(ACTIVE, groupState != null && gv2GroupActive(groupState) ? 1 : 0); + } else { + contentValues.put(ACTIVE, 1); + } + contentValues.put(MMS, groupId.isMms()); 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 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 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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducer.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducer.java index db52f3d0a5..614c23fd28 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducer.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducer.java @@ -584,7 +584,13 @@ 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 { - updates.add(updateDescription(change.getEditor(), requestingMember.getUuid(), (editor, requesting) -> context.getString(R.string.MessageRecord_s_approved_a_request_to_join_the_group_from_s, editor, requesting))); + boolean editorIsYou = change.getEditor().equals(selfUuidBytes); + + if (editorIsYou) { + updates.add(updateDescription(requestingMember.getUuid(), requesting -> context.getString(R.string.MessageRecord_you_approved_a_request_to_join_the_group_from_s, requesting))); + } else { + updates.add(updateDescription(change.getEditor(), requestingMember.getUuid(), (editor, requesting) -> context.getString(R.string.MessageRecord_s_approved_a_request_to_join_the_group_from_s, editor, requesting))); + } } } } @@ -602,13 +608,25 @@ final class GroupsV2UpdateMessageProducer { } private void describeRequestingMembersDeletes(@NonNull DecryptedGroupChange change, @NonNull List updates) { + boolean editorIsYou = change.getEditor().equals(selfUuidBytes); + for (ByteString requestingMember : change.getDeleteRequestingMembersList()) { boolean requestingMemberIsYou = requestingMember.equals(selfUuidBytes); if (requestingMemberIsYou) { - updates.add(updateDescription(context.getString(R.string.MessageRecord_your_request_to_join_the_group_has_been_denied_by_an_admin))); + if (editorIsYou) { + updates.add(updateDescription(context.getString(R.string.MessageRecord_you_canceled_your_request_to_join_the_group))); + } else { + updates.add(updateDescription(context.getString(R.string.MessageRecord_your_request_to_join_the_group_has_been_denied_by_an_admin))); + } } else { - updates.add(updateDescription(change.getEditor(), requestingMember, (editor, requesting) -> context.getString(R.string.MessageRecord_s_denied_a_request_to_join_the_group_from_s, editor, requesting))); + boolean editorIsCanceledMember = change.getEditor().equals(requestingMember); + + if (editorIsCanceledMember) { + updates.add(updateDescription(requestingMember, editorRequesting -> context.getString(R.string.MessageRecord_s_canceled_their_request_to_join_the_group, editorRequesting))); + } else { + updates.add(updateDescription(change.getEditor(), requestingMember, (editor, requesting) -> context.getString(R.string.MessageRecord_s_denied_a_request_to_join_the_group_from_s, editor, requesting))); + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java index 50bbedf36f..30a510f618 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java @@ -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()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupJoinAlreadyAMemberException.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupJoinAlreadyAMemberException.java new file mode 100644 index 0000000000..da93ced790 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupJoinAlreadyAMemberException.java @@ -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); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java index 141550b2cb..d1a7113818 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java @@ -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; diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java index a2082746e6..1a03c7dd51 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java @@ -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 recipientIds) + throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException + { + Set 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 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 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. + *

+ * 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 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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupErrors.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupErrors.java index 7eca06efe2..2c373d109f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupErrors.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupErrors.java @@ -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; diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupDetails.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupDetails.java index 25940cfd7a..37db0fa56a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupDetails.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupDetails.java @@ -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 byte[] avatarBytes; - private final int groupMembershipCount; - private final boolean requiresAdminApproval; - private final int groupRevision; + private final DecryptedGroupJoinInfo joinInfo; + private final byte[] avatarBytes; - public GroupDetails(String groupName, - byte[] avatarBytes, - int groupMembershipCount, - boolean requiresAdminApproval, - int groupRevision) + public GroupDetails(@NonNull DecryptedGroupJoinInfo joinInfo, + @Nullable byte[] avatarBytes) { - this.groupName = groupName; - this.avatarBytes = avatarBytes; - this.groupMembershipCount = groupMembershipCount; - this.requiresAdminApproval = requiresAdminApproval; - this.groupRevision = groupRevision; + this.joinInfo = joinInfo; + this.avatarBytes = avatarBytes; } - 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; } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinBottomSheetDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinBottomSheetDialogFragment.java index b8fe2fe4e5..0b0589399a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinBottomSheetDialogFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinBottomSheetDialogFragment.java @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinRepository.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinRepository.java index 83fee2d46c..d67a7154d6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinRepository.java @@ -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 callback) { SignalExecutors.UNBOUNDED.execute(() -> { try { callback.onComplete(getGroupDetails()); @@ -43,6 +45,30 @@ final class GroupJoinRepository { }); } + void joinGroup(@NonNull GroupDetails groupDetails, + @NonNull AsynchronousCallback.WorkerThread 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 @@ -51,14 +77,9 @@ final class GroupJoinRepository { groupInviteLinkUrl.getGroupMasterKey(), groupInviteLinkUrl.getPassword()); - byte[] avatarBytes = tryGetAvatarBytes(joinInfo); - boolean requiresAdminApproval = joinInfo.getAddFromInviteLink() == AccessControl.AccessRequired.ADMINISTRATOR; + byte[] avatarBytes = tryGetAvatarBytes(joinInfo); - 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); - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinViewModel.java index f3102a0796..767d9b1f35 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinViewModel.java @@ -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 = new MutableLiveData<>(); private final MutableLiveData errors = new SingleLiveEvent<>(); + private final MutableLiveData joinErrors = new SingleLiveEvent<>(); private final MutableLiveData busy = new MediatorLiveData<>(); + private final MutableLiveData joinSuccess = new SingleLiveEvent<>(); private GroupJoinViewModel(@NonNull GroupJoinRepository repository) { + this.repository = repository; + busy.setValue(true); - repository.getGroupDetails(new GroupJoinRepository.GetGroupDetailsCallback() { + repository.getGroupDetails(new AsynchronousCallback.WorkerThread() { @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() { + @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 getGroupDetails() { return groupDetails; } + LiveData getJoinSuccess() { + return joinSuccess; + } + LiveData isBusy() { return busy; } @@ -47,6 +75,10 @@ public class GroupJoinViewModel extends ViewModel { return errors; } + LiveData getJoinErrors() { + return joinErrors; + } + public static class Factory implements ViewModelProvider.Factory { private final Context context; diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/JoinGroupError.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/JoinGroupError.java new file mode 100644 index 0000000000..059b2ce2b4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/JoinGroupError.java @@ -0,0 +1,8 @@ +package org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining; + +enum JoinGroupError { + BUSY, + GROUP_LINK_NOT_ACTIVE, + FAILED, + NETWORK_ERROR, +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/JoinGroupSuccess.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/JoinGroupSuccess.java new file mode 100644 index 0000000000..59655cc09b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/JoinGroupSuccess.java @@ -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; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupStateMapper.java b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupStateMapper.java index d1ab297776..8b29015777 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupStateMapper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupStateMapper.java @@ -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 LATEST = Integer.MAX_VALUE; + static final int PLACEHOLDER_REVISION = -1; private static final Comparator 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 appliedChanges = new ArrayList<>(inputState.getServerHistory().size()); - HashMap statesToApplyNow = new HashMap<>(inputState.getServerHistory().size()); - ArrayList statesToApplyLater = new ArrayList<>(inputState.getServerHistory().size()); - DecryptedGroup current = inputState.getLocalState(); + AdvanceGroupStateResult groupStateResult = processChanges(inputState, maximumRevisionToApply); + + return cleanDuplicatedChanges(groupStateResult, inputState.getLocalState()); + } + + private static @NonNull AdvanceGroupStateResult processChanges(@NonNull GlobalGroupState inputState, + int maximumRevisionToApply) + { + HashMap statesToApplyNow = new HashMap<>(inputState.getServerHistory().size()); + ArrayList statesToApplyLater = new ArrayList<>(inputState.getServerHistory().size()); + DecryptedGroup current = inputState.getLocalState(); + StateChain 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); - } - - if (changeAtRevision == null) { - Log.w(TAG, "Reconstructing change for V" + revision); - changeAtRevision = GroupChangeReconstruct.reconstructGroupChange(current, Objects.requireNonNull(groupAtRevision)); - } - - DecryptedGroup groupWithChangeApplied; - try { - groupWithChangeApplied = DecryptedGroupUtil.applyWithoutRevisionCheck(current, changeAtRevision); - } catch (NotAbleToApplyGroupV2ChangeException e) { - Log.w(TAG, "Unable to apply V" + revision, e); - continue; - } - - if (groupAtRevision == null) { - Log.w(TAG, "Reconstructing state for V" + revision); - groupAtRevision = groupWithChangeApplied; - } - - if (current.getRevision() != groupAtRevision.getRevision()) { - appliedChanges.add(new LocalGroupLogEntry(groupAtRevision, changeAtRevision)); - } else { - DecryptedGroupChange sameRevisionDelta = GroupChangeReconstruct.reconstructGroupChange(current, groupAtRevision); - if (!DecryptedGroupUtil.changeIsEmpty(sameRevisionDelta)) { - appliedChanges.add(new LocalGroupLogEntry(groupAtRevision, sameRevisionDelta)); - Log.w(TAG, "Inserted repair change for mismatch V" + revision); - } - } - - DecryptedGroupChange missingChanges = GroupChangeReconstruct.reconstructGroupChange(groupWithChangeApplied, groupAtRevision); - if (!DecryptedGroupUtil.changeIsEmpty(missingChanges)) { - appliedChanges.add(new LocalGroupLogEntry(groupAtRevision, missingChanges)); - Log.w(TAG, "Inserted repair change for gap V" + revision); - } - - current = groupAtRevision; + stateChain.push(entry.getGroup(), entry.getChange()); } - return new AdvanceGroupStateResult(appliedChanges, new GlobalGroupState(current, statesToApplyLater)); + List> mapperList = stateChain.getList(); + List appliedChanges = new ArrayList<>(mapperList.size()); + + for (StateChain.Pair entry : mapperList) { + if (current == null || entry.getDelta() != null) { + appliedChanges.add(new LocalGroupLogEntry(entry.getState(), entry.getDelta())); + } + } + + return new AdvanceGroupStateResult(appliedChanges, new GlobalGroupState(stateChain.getLatestState(), statesToApplyLater)); + } + + private static AdvanceGroupStateResult cleanDuplicatedChanges(@NonNull AdvanceGroupStateResult groupStateResult, + @Nullable DecryptedGroup previousGroupState) + { + if (previousGroupState == null) return groupStateResult; + + ArrayList 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 createNewMapper() { + return new StateChain<>( + (group, change) -> { + try { + return DecryptedGroupUtil.applyWithoutRevisionCheck(group, change); + } catch (NotAbleToApplyGroupV2ChangeException e) { + Log.w(TAG, "Unable to apply V" + change.getRevision(), e); + return null; + } + }, + (groupB, groupA) -> GroupChangeReconstruct.reconstructGroupChange(groupA, groupB), + (groupA, groupB) -> DecryptedGroupUtil.changeIsEmpty(GroupChangeReconstruct.reconstructGroupChange(groupA, groupB)) + ); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java index 93be066643..11690c5637 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java @@ -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; @@ -65,7 +63,8 @@ public final class GroupsV2StateProcessor { private static final String TAG = Log.tag(GroupsV2StateProcessor.class); - public static final int LATEST = GroupStateMapper.LATEST; + public static final int LATEST = GroupStateMapper.LATEST; + public static final int PLACEHOLDER_REVISION = GroupStateMapper.PLACEHOLDER_REVISION; private final Context context; private final JobManager jobManager; @@ -177,9 +176,26 @@ public final class GroupsV2StateProcessor { try { inputGroupState = queryServer(localState, revision == LATEST && localState == null); } catch (GroupNotAMemberException e) { - Log.w(TAG, "Unable to query server for group " + groupId + " server says we're not in group, inserting leave message"); - insertGroupLeave(); - throw 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"); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/StateChain.java b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/StateChain.java new file mode 100644 index 0000000000..25cd7ecf5c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/StateChain.java @@ -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: + *

+ * {@code
+ *  (S1, Delta1),
+ *  (S2, Delta2),
+ *  (S3, Delta3)
+ * }
+ * 
+ * Such that the states always include all deltas. + *
+ * {@code
+ *  (S1, _),
+ *  (S1 + Delta2, Delta2),
+ *  (S1 + Delta2 + Delta3, Delta3),
+ * }
+ * 
+ *

+ * If a pushed delta does not correct create the new state (tested by {@link StateEquality}), a new + * delta and state is inserted like so: + *

+ * {@code
+ * (PreviousState, PreviousDelta),
+ * (PreviousState + NewDelta, NewDelta),
+ * (NewState, PreviousState + NewDelta - NewState),
+ * }
+ * 
+ * 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 { + + private final AddDelta add; + private final SubtractStates subtract; + private final StateEquality stateEquality; + + private final List> pairs = new LinkedList<>(); + + public StateChain(@NonNull AddDelta add, + @NonNull SubtractStates subtract, + @NonNull StateEquality 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> getList() { + return new ArrayList<>(pairs); + } + + public static final class Pair { + @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 { + + /** + * Add {@param delta} to {@param state} and return the new {@link State}. + *

+ * 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 { + + /** + * 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 { + + boolean equals(@NonNull State stateA, @NonNull State stateB); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java index 36d8345142..57d84b055f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java @@ -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()); + 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; } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/WakeGroupV2Job.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/WakeGroupV2Job.java index 0400d235f1..1087958420 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/WakeGroupV2Job.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/WakeGroupV2Job.java @@ -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 group = groupDatabase.getGroup(groupId); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/AsynchronousCallback.java b/app/src/main/java/org/thoughtcrime/securesms/util/AsynchronousCallback.java new file mode 100644 index 0000000000..f0fdcbe4e8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/AsynchronousCallback.java @@ -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. + *

+ * Using the original thread used for operation to invoke the callback methods. + *

+ * The contract is that exactly one method on the callback will be called, exactly once. + * + * @param Result type + * @param Error type + */ + public interface WorkerThread { + + @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. + *

+ * Using the main thread used for operation to invoke the callback methods. + *

+ * The contract is that exactly one method on the callback will be called, exactly once. + * + * @param Result type + * @param Error type + */ + public interface MainThread { + + @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 toWorkerCallback() { + return new WorkerThread() { + @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)); + } + }; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java b/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java index e8f91d21e7..58ceab0bed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java @@ -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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Debouncer.java b/app/src/main/java/org/thoughtcrime/securesms/util/Debouncer.java index 9daf2c1cb0..eabd1ff9d4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/Debouncer.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Debouncer.java @@ -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; } diff --git a/app/src/main/res/layout/conversation_activity.xml b/app/src/main/res/layout/conversation_activity.xml index b5e1a80da9..28cffbec8e 100644 --- a/app/src/main/res/layout/conversation_activity.xml +++ b/app/src/main/res/layout/conversation_activity.xml @@ -99,6 +99,10 @@ layout="@layout/conversation_no_longer_a_member" android:visibility="gone" /> + + diff --git a/app/src/main/res/layout/conversation_requesting_bottom_banner.xml b/app/src/main/res/layout/conversation_requesting_bottom_banner.xml new file mode 100644 index 0000000000..d1a0aa4d3f --- /dev/null +++ b/app/src/main/res/layout/conversation_requesting_bottom_banner.xml @@ -0,0 +1,28 @@ + + + + + +