mirror of
https://github.com/oxen-io/session-android.git
synced 2025-08-26 09:27:54 +00:00
Join group via invite link.
This commit is contained in:
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
@@ -1,6 +1,7 @@
|
||||
package org.thoughtcrime.securesms.conversation;
|
||||
|
||||
import android.content.Context;
|
||||
import android.text.TextUtils;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
import android.widget.TextView;
|
||||
@@ -54,6 +55,7 @@ public class ConversationBannerView extends ConstraintLayout {
|
||||
|
||||
public void setSubtitle(@Nullable CharSequence subtitle) {
|
||||
contactSubtitle.setText(subtitle);
|
||||
contactSubtitle.setVisibility(TextUtils.isEmpty(subtitle) ? GONE : VISIBLE);
|
||||
}
|
||||
|
||||
public void setDescription(@Nullable CharSequence description) {
|
||||
|
@@ -77,8 +77,8 @@ import org.thoughtcrime.securesms.conversation.ConversationAdapter.ItemClickList
|
||||
import org.thoughtcrime.securesms.conversation.ConversationAdapter.StickyHeaderViewHolder;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.MmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.MessageDatabase;
|
||||
import org.thoughtcrime.securesms.database.MmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
||||
import org.thoughtcrime.securesms.database.SmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
|
||||
@@ -402,9 +402,11 @@ public class ConversationFragment extends LoggingFragment {
|
||||
conversationBanner.setSubtitle(context.getResources()
|
||||
.getQuantityString(R.plurals.MessageRequestProfileView_members_and_invited, memberCount,
|
||||
memberCount, pendingMemberCount));
|
||||
} else {
|
||||
} else if (memberCount > 0) {
|
||||
conversationBanner.setSubtitle(context.getResources().getQuantityString(R.plurals.MessageRequestProfileView_members, memberCount,
|
||||
memberCount));
|
||||
} else {
|
||||
conversationBanner.setSubtitle(null);
|
||||
}
|
||||
} else if (isSelf) {
|
||||
conversationBanner.setSubtitle(context.getString(R.string.ConversationFragment__you_can_add_notes_for_yourself_in_this_conversation));
|
||||
|
@@ -14,18 +14,30 @@ import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase;
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase.GroupRecord;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.groups.GroupChangeBusyException;
|
||||
import org.thoughtcrime.securesms.groups.GroupChangeFailedException;
|
||||
import org.thoughtcrime.securesms.groups.GroupManager;
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.AsynchronousCallback;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
|
||||
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
|
||||
|
||||
class ConversationGroupViewModel extends ViewModel {
|
||||
import java.io.IOException;
|
||||
|
||||
private final MutableLiveData<Recipient> liveRecipient;
|
||||
private final LiveData<GroupActiveState> groupActiveState;
|
||||
final class ConversationGroupViewModel extends ViewModel {
|
||||
|
||||
private final MutableLiveData<Recipient> liveRecipient;
|
||||
private final LiveData<GroupActiveState> groupActiveState;
|
||||
private final LiveData<GroupDatabase.MemberLevel> selfMembershipLevel;
|
||||
|
||||
private ConversationGroupViewModel() {
|
||||
liveRecipient = new MutableLiveData<>();
|
||||
LiveData<GroupRecord> groupRecord = LiveDataUtil.mapAsync(liveRecipient, this::getGroupRecordForRecipient);
|
||||
groupActiveState = Transformations.distinctUntilChanged(Transformations.map(groupRecord, this::mapToGroupActiveState));
|
||||
this.liveRecipient = new MutableLiveData<>();
|
||||
|
||||
LiveData<GroupRecord> groupRecord = LiveDataUtil.mapAsync(liveRecipient, ConversationGroupViewModel::getGroupRecordForRecipient);
|
||||
|
||||
this.groupActiveState = Transformations.distinctUntilChanged(Transformations.map(groupRecord, ConversationGroupViewModel::mapToGroupActiveState));
|
||||
this.selfMembershipLevel = Transformations.distinctUntilChanged(Transformations.map(groupRecord, ConversationGroupViewModel::mapToSelfMembershipLevel));
|
||||
}
|
||||
|
||||
void onRecipientChange(Recipient recipient) {
|
||||
@@ -36,7 +48,11 @@ class ConversationGroupViewModel extends ViewModel {
|
||||
return groupActiveState;
|
||||
}
|
||||
|
||||
private GroupRecord getGroupRecordForRecipient(Recipient recipient) {
|
||||
LiveData<GroupDatabase.MemberLevel> getSelfMemberLevel() {
|
||||
return selfMembershipLevel;
|
||||
}
|
||||
|
||||
private static @Nullable GroupRecord getGroupRecordForRecipient(@Nullable Recipient recipient) {
|
||||
if (recipient != null && recipient.isGroup()) {
|
||||
Application context = ApplicationDependencies.getApplication();
|
||||
GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context);
|
||||
@@ -46,13 +62,37 @@ class ConversationGroupViewModel extends ViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
private GroupActiveState mapToGroupActiveState(@Nullable GroupRecord record) {
|
||||
private static GroupActiveState mapToGroupActiveState(@Nullable GroupRecord record) {
|
||||
if (record == null) {
|
||||
return null;
|
||||
}
|
||||
return new GroupActiveState(record.isActive(), record.isV2Group());
|
||||
}
|
||||
|
||||
private static GroupDatabase.MemberLevel mapToSelfMembershipLevel(@Nullable GroupRecord record) {
|
||||
if (record == null) {
|
||||
return null;
|
||||
}
|
||||
return record.memberLevel(Recipient.self());
|
||||
}
|
||||
|
||||
public static void onCancelJoinRequest(@NonNull Recipient recipient,
|
||||
@NonNull AsynchronousCallback.WorkerThread<Void, GroupChangeFailureReason> callback)
|
||||
{
|
||||
SignalExecutors.UNBOUNDED.execute(() -> {
|
||||
if (!recipient.isPushV2Group()) {
|
||||
throw new AssertionError();
|
||||
}
|
||||
|
||||
try {
|
||||
GroupManager.cancelJoinRequest(ApplicationDependencies.getApplication(), recipient.getGroupId().get().requireV2());
|
||||
callback.onComplete(null);
|
||||
} catch (GroupChangeFailedException | GroupChangeBusyException | IOException e) {
|
||||
callback.onError(GroupChangeFailureReason.fromException(e));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static final class GroupActiveState {
|
||||
private final boolean isActive;
|
||||
private final boolean isActiveV2;
|
||||
|
@@ -129,6 +129,15 @@ public final class GroupDatabase extends Database {
|
||||
}
|
||||
}
|
||||
|
||||
public boolean findGroup(@NonNull GroupId groupId) {
|
||||
try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, GROUP_ID + " = ?",
|
||||
new String[] {groupId.toString()},
|
||||
null, null, null))
|
||||
{
|
||||
return cursor.moveToNext();
|
||||
}
|
||||
}
|
||||
|
||||
Optional<GroupRecord> getGroup(Cursor cursor) {
|
||||
Reader reader = new Reader(cursor);
|
||||
return Optional.fromNullable(reader.getCurrent());
|
||||
@@ -364,7 +373,13 @@ public final class GroupDatabase extends Database {
|
||||
|
||||
contentValues.put(AVATAR_RELAY, relay);
|
||||
contentValues.put(TIMESTAMP, System.currentTimeMillis());
|
||||
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<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);
|
||||
|
||||
|
@@ -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<UpdateDescription> 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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -174,7 +174,7 @@ public abstract class MessageRecord extends DisplayRecord {
|
||||
DecryptedGroupV2Context decryptedGroupV2Context = DecryptedGroupV2Context.parseFrom(decoded);
|
||||
GroupsV2UpdateMessageProducer updateMessageProducer = new GroupsV2UpdateMessageProducer(context, descriptionStrategy, Recipient.self().getUuid().get());
|
||||
|
||||
if (decryptedGroupV2Context.hasChange() && decryptedGroupV2Context.getGroupState().getRevision() > 0) {
|
||||
if (decryptedGroupV2Context.hasChange() && decryptedGroupV2Context.getGroupState().getRevision() != 0) {
|
||||
return UpdateDescription.concatWithNewLines(updateMessageProducer.describeChanges(decryptedGroupV2Context.getChange()));
|
||||
} else {
|
||||
return updateMessageProducer.describeNewGroup(decryptedGroupV2Context.getGroupState());
|
||||
|
@@ -0,0 +1,10 @@
|
||||
package org.thoughtcrime.securesms.groups;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
public final class GroupJoinAlreadyAMemberException extends GroupChangeException {
|
||||
|
||||
GroupJoinAlreadyAMemberException(@NonNull Throwable throwable) {
|
||||
super(throwable);
|
||||
}
|
||||
}
|
@@ -145,6 +145,12 @@ public final class GroupManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws GroupNotAMemberException When Self is not a member of the group.
|
||||
* The exception to this is when Self is a requesting member and
|
||||
* there is a supplied signedGroupChange. This allows for
|
||||
* processing deny messages.
|
||||
*/
|
||||
@WorkerThread
|
||||
public static void updateGroupFromServer(@NonNull Context context,
|
||||
@NonNull GroupMasterKey groupMasterKey,
|
||||
@@ -174,6 +180,11 @@ public final class GroupManager {
|
||||
public static void updateSelfProfileKeyInGroup(@NonNull Context context, @NonNull GroupId.V2 groupId)
|
||||
throws IOException, GroupChangeBusyException, GroupInsufficientRightsException, GroupNotAMemberException, GroupChangeFailedException
|
||||
{
|
||||
if (!DatabaseFactory.getGroupDatabase(context).findGroup(groupId)) {
|
||||
Log.i(TAG, "Group is not available locally " + groupId);
|
||||
return;
|
||||
}
|
||||
|
||||
try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) {
|
||||
editor.updateSelfProfileKeyInGroup();
|
||||
}
|
||||
@@ -266,12 +277,35 @@ public final class GroupManager {
|
||||
@WorkerThread
|
||||
public static @NonNull DecryptedGroupJoinInfo getGroupJoinInfoFromServer(@NonNull Context context,
|
||||
@NonNull GroupMasterKey groupMasterKey,
|
||||
@NonNull GroupLinkPassword groupLinkPassword)
|
||||
@Nullable GroupLinkPassword groupLinkPassword)
|
||||
throws IOException, VerificationFailedException, GroupLinkNotActiveException
|
||||
{
|
||||
return new GroupManagerV2(context).getGroupJoinInfoFromServer(groupMasterKey, groupLinkPassword);
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public static GroupActionResult joinGroup(@NonNull Context context,
|
||||
@NonNull GroupMasterKey groupMasterKey,
|
||||
@NonNull GroupLinkPassword groupLinkPassword,
|
||||
@NonNull DecryptedGroupJoinInfo decryptedGroupJoinInfo,
|
||||
@Nullable byte[] avatar)
|
||||
throws IOException, GroupChangeBusyException, GroupChangeFailedException, MembershipNotSuitableForV2Exception, GroupLinkNotActiveException
|
||||
{
|
||||
try (GroupManagerV2.GroupJoiner join = new GroupManagerV2(context).join(groupMasterKey, groupLinkPassword)) {
|
||||
return join.joinGroup(decryptedGroupJoinInfo, avatar);
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public static void cancelJoinRequest(@NonNull Context context,
|
||||
@NonNull GroupId.V2 groupId)
|
||||
throws GroupChangeFailedException, IOException, GroupChangeBusyException
|
||||
{
|
||||
try (GroupManagerV2.GroupJoiner editor = new GroupManagerV2(context).cancelRequest(groupId.requireV2())) {
|
||||
editor.cancelJoinRequest();
|
||||
}
|
||||
}
|
||||
|
||||
public static class GroupActionResult {
|
||||
private final Recipient groupRecipient;
|
||||
private final long threadId;
|
||||
|
@@ -6,7 +6,9 @@ import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import com.annimon.stream.Collectors;
|
||||
import com.annimon.stream.Stream;
|
||||
import com.google.protobuf.ByteString;
|
||||
import com.google.protobuf.InvalidProtocolBufferException;
|
||||
|
||||
import org.signal.storageservice.protos.groups.AccessControl;
|
||||
@@ -17,21 +19,25 @@ import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroupJoinInfo;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedMember;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedPendingMember;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedRequestingMember;
|
||||
import org.signal.zkgroup.InvalidInputException;
|
||||
import org.signal.zkgroup.VerificationFailedException;
|
||||
import org.signal.zkgroup.groups.GroupMasterKey;
|
||||
import org.signal.zkgroup.groups.GroupSecretParams;
|
||||
import org.signal.zkgroup.groups.UuidCiphertext;
|
||||
import org.signal.zkgroup.profiles.ProfileKey;
|
||||
import org.signal.zkgroup.profiles.ProfileKeyCredential;
|
||||
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase;
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.groups.v2.GroupCandidateHelper;
|
||||
import org.thoughtcrime.securesms.groups.v2.GroupLinkPassword;
|
||||
import org.thoughtcrime.securesms.groups.v2.processing.GroupsV2StateProcessor;
|
||||
import org.thoughtcrime.securesms.jobs.PushGroupSilentUpdateSendJob;
|
||||
import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.mms.OutgoingGroupUpdateMessage;
|
||||
@@ -62,6 +68,7 @@ import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -89,12 +96,14 @@ final class GroupManagerV2 {
|
||||
this.groupCandidateHelper = new GroupCandidateHelper(context);
|
||||
}
|
||||
|
||||
@NonNull DecryptedGroupJoinInfo getGroupJoinInfoFromServer(@NonNull GroupMasterKey groupMasterKey, @NonNull GroupLinkPassword password)
|
||||
@NonNull DecryptedGroupJoinInfo getGroupJoinInfoFromServer(@NonNull GroupMasterKey groupMasterKey, @Nullable GroupLinkPassword password)
|
||||
throws IOException, VerificationFailedException, GroupLinkNotActiveException
|
||||
{
|
||||
GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
|
||||
|
||||
return groupsV2Api.getGroupJoinInfo(groupSecretParams, password.serialize(), authorization.getAuthorizationForToday(Recipient.self().requireUuid(), groupSecretParams));
|
||||
return groupsV2Api.getGroupJoinInfo(groupSecretParams,
|
||||
Optional.fromNullable(password).transform(GroupLinkPassword::serialize),
|
||||
authorization.getAuthorizationForToday(Recipient.self().requireUuid(), groupSecretParams));
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
@@ -107,17 +116,30 @@ final class GroupManagerV2 {
|
||||
return new GroupEditor(groupId, GroupsV2ProcessingLock.acquireGroupProcessingLock());
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
GroupJoiner join(@NonNull GroupMasterKey groupMasterKey, @NonNull GroupLinkPassword password) throws GroupChangeBusyException {
|
||||
return new GroupJoiner(groupMasterKey, password, GroupsV2ProcessingLock.acquireGroupProcessingLock());
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
GroupJoiner cancelRequest(@NonNull GroupId.V2 groupId) throws GroupChangeBusyException {
|
||||
GroupMasterKey groupMasterKey = DatabaseFactory.getGroupDatabase(context)
|
||||
.requireGroup(groupId)
|
||||
.requireV2GroupProperties()
|
||||
.getGroupMasterKey();
|
||||
|
||||
return new GroupJoiner(groupMasterKey, null, GroupsV2ProcessingLock.acquireGroupProcessingLock());
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
GroupUpdater updater(@NonNull GroupMasterKey groupId) throws GroupChangeBusyException {
|
||||
return new GroupUpdater(groupId, GroupsV2ProcessingLock.acquireGroupProcessingLock());
|
||||
}
|
||||
|
||||
class GroupCreator implements Closeable {
|
||||
|
||||
private final Closeable lock;
|
||||
final class GroupCreator extends LockOwner {
|
||||
|
||||
GroupCreator(@NonNull Closeable lock) {
|
||||
this.lock = lock;
|
||||
super(lock);
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
@@ -176,26 +198,21 @@ final class GroupManagerV2 {
|
||||
throw new GroupChangeFailedException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
lock.close();
|
||||
}
|
||||
}
|
||||
|
||||
class GroupEditor implements Closeable {
|
||||
final class GroupEditor extends LockOwner {
|
||||
|
||||
private final Closeable lock;
|
||||
private final GroupId.V2 groupId;
|
||||
private final GroupMasterKey groupMasterKey;
|
||||
private final GroupSecretParams groupSecretParams;
|
||||
private final GroupsV2Operations.GroupOperations groupOperations;
|
||||
|
||||
GroupEditor(@NonNull GroupId.V2 groupId, @NonNull Closeable lock) {
|
||||
super(lock);
|
||||
|
||||
GroupDatabase.GroupRecord groupRecord = groupDatabase.requireGroup(groupId);
|
||||
GroupDatabase.V2GroupProperties v2GroupProperties = groupRecord.requireV2GroupProperties();
|
||||
|
||||
this.lock = lock;
|
||||
this.groupId = groupId;
|
||||
this.groupMasterKey = v2GroupProperties.getGroupMasterKey();
|
||||
this.groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
|
||||
@@ -275,6 +292,17 @@ final class GroupManagerV2 {
|
||||
return commitChangeWithConflictResolution(groupOperations.createRemoveInvitationChange(new HashSet<>(uuidCipherTexts)));
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
@NonNull GroupManager.GroupActionResult denyRequests(@NonNull Collection<RecipientId> recipientIds)
|
||||
throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException
|
||||
{
|
||||
Set<UUID> uuids = Stream.of(recipientIds)
|
||||
.map(r -> Recipient.resolved(r).getUuid().get())
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
return commitChangeWithConflictResolution(groupOperations.createRefuseGroupJoinRequest(uuids));
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
@NonNull GroupManager.GroupActionResult setMemberAdmin(@NonNull RecipientId recipientId,
|
||||
boolean admin)
|
||||
@@ -333,7 +361,7 @@ final class GroupManagerV2 {
|
||||
Optional<DecryptedMember> selfInGroup = DecryptedGroupUtil.findMemberByUuid(group.getMembersList(), selfUuid);
|
||||
|
||||
if (!selfInGroup.isPresent()) {
|
||||
Log.w(TAG, "Self not in group");
|
||||
Log.w(TAG, "Self not in group " + groupId);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -456,7 +484,7 @@ final class GroupManagerV2 {
|
||||
throws GroupNotAMemberException, GroupChangeFailedException, IOException, GroupInsufficientRightsException
|
||||
{
|
||||
try {
|
||||
return groupsV2Api.patchGroup(change, authorization.getAuthorizationForToday(selfUuid, groupSecretParams));
|
||||
return groupsV2Api.patchGroup(change, authorization.getAuthorizationForToday(selfUuid, groupSecretParams), Optional.absent());
|
||||
} catch (NotInGroupException e) {
|
||||
Log.w(TAG, e);
|
||||
throw new GroupNotAMemberException(e);
|
||||
@@ -468,20 +496,15 @@ final class GroupManagerV2 {
|
||||
throw new GroupChangeFailedException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
lock.close();
|
||||
}
|
||||
}
|
||||
|
||||
class GroupUpdater implements Closeable {
|
||||
final class GroupUpdater extends LockOwner {
|
||||
|
||||
private final Closeable lock;
|
||||
private final GroupMasterKey groupMasterKey;
|
||||
|
||||
GroupUpdater(@NonNull GroupMasterKey groupMasterKey, @NonNull Closeable lock) {
|
||||
this.lock = lock;
|
||||
super(lock);
|
||||
|
||||
this.groupMasterKey = groupMasterKey;
|
||||
}
|
||||
|
||||
@@ -507,6 +530,377 @@ final class GroupManagerV2 {
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
final class GroupJoiner extends LockOwner {
|
||||
private final GroupId.V2 groupId;
|
||||
private final GroupLinkPassword password;
|
||||
private final GroupSecretParams groupSecretParams;
|
||||
private final GroupsV2Operations.GroupOperations groupOperations;
|
||||
private final GroupMasterKey groupMasterKey;
|
||||
|
||||
public GroupJoiner(@NonNull GroupMasterKey groupMasterKey,
|
||||
@Nullable GroupLinkPassword password,
|
||||
@NonNull Closeable lock)
|
||||
{
|
||||
super(lock);
|
||||
|
||||
this.groupId = GroupId.v2(groupMasterKey);
|
||||
this.password = password;
|
||||
this.groupMasterKey = groupMasterKey;
|
||||
this.groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
|
||||
this.groupOperations = groupsV2Operations.forGroup(groupSecretParams);
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public GroupManager.GroupActionResult joinGroup(@NonNull DecryptedGroupJoinInfo joinInfo,
|
||||
@Nullable byte[] avatar)
|
||||
throws GroupChangeFailedException, IOException, MembershipNotSuitableForV2Exception, GroupLinkNotActiveException
|
||||
{
|
||||
boolean requestToJoin = joinInfo.getAddFromInviteLink() == AccessControl.AccessRequired.ADMINISTRATOR;
|
||||
boolean alreadyAMember = false;
|
||||
|
||||
if (requestToJoin) {
|
||||
Log.i(TAG, "Requesting to join " + groupId);
|
||||
} else {
|
||||
Log.i(TAG, "Joining " + groupId);
|
||||
}
|
||||
|
||||
GroupChange signedGroupChange = null;
|
||||
DecryptedGroupChange decryptedChange = null;
|
||||
try {
|
||||
signedGroupChange = joinGroupOnServer(requestToJoin, joinInfo.getRevision());
|
||||
|
||||
if (requestToJoin) {
|
||||
Log.i(TAG, String.format("Successfully requested to join %s on server", groupId));
|
||||
} else {
|
||||
Log.i(TAG, String.format("Successfully added self to %s on server", groupId));
|
||||
}
|
||||
|
||||
decryptedChange = decryptChange(signedGroupChange);
|
||||
} catch (GroupJoinAlreadyAMemberException e) {
|
||||
Log.i(TAG, "Server reports that we are already a member of " + groupId);
|
||||
alreadyAMember = true;
|
||||
}
|
||||
|
||||
DecryptedGroup decryptedGroup = createPlaceholderGroup(joinInfo, requestToJoin);
|
||||
|
||||
Optional<GroupDatabase.GroupRecord> group = groupDatabase.getGroup(groupId);
|
||||
if (group.isPresent()) {
|
||||
Log.i(TAG, "Group already present locally");
|
||||
|
||||
DecryptedGroup currentGroupState = group.get()
|
||||
.requireV2GroupProperties()
|
||||
.getDecryptedGroup();
|
||||
|
||||
DecryptedGroup updatedGroup = currentGroupState;
|
||||
|
||||
try {
|
||||
if (decryptedChange != null) {
|
||||
updatedGroup = DecryptedGroupUtil.applyWithoutRevisionCheck(updatedGroup, decryptedChange);
|
||||
}
|
||||
updatedGroup = resetRevision(updatedGroup, currentGroupState.getRevision());
|
||||
} catch (NotAbleToApplyGroupV2ChangeException e) {
|
||||
Log.w(TAG, e);
|
||||
updatedGroup = decryptedGroup;
|
||||
}
|
||||
|
||||
groupDatabase.update(groupId, updatedGroup);
|
||||
} else {
|
||||
groupDatabase.create(groupMasterKey, decryptedGroup);
|
||||
Log.i(TAG, "Created local group with placeholder");
|
||||
}
|
||||
|
||||
RecipientId groupRecipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId);
|
||||
Recipient groupRecipient = Recipient.resolved(groupRecipientId);
|
||||
|
||||
AvatarHelper.setAvatar(context, groupRecipientId, avatar != null ? new ByteArrayInputStream(avatar) : null);
|
||||
groupDatabase.onAvatarUpdated(groupId, avatar != null);
|
||||
DatabaseFactory.getRecipientDatabase(context).setProfileSharing(groupRecipientId, true);
|
||||
|
||||
if (alreadyAMember) {
|
||||
Log.i(TAG, "Already a member of the group");
|
||||
|
||||
ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context);
|
||||
long threadId = threadDatabase.getOrCreateValidThreadId(groupRecipient, -1);
|
||||
|
||||
return new GroupManager.GroupActionResult(groupRecipient,
|
||||
threadId,
|
||||
0,
|
||||
Collections.emptyList());
|
||||
} else if (requestToJoin) {
|
||||
Log.i(TAG, "Requested to join, cannot send update");
|
||||
|
||||
RecipientAndThread recipientAndThread = sendGroupUpdate(groupMasterKey, decryptedGroup, decryptedChange, signedGroupChange);
|
||||
|
||||
return new GroupManager.GroupActionResult(groupRecipient,
|
||||
recipientAndThread.threadId,
|
||||
0,
|
||||
Collections.emptyList());
|
||||
} else {
|
||||
Log.i(TAG, "Joined group on server, fetching group state and sending update");
|
||||
|
||||
return fetchGroupStateAndSendUpdate(groupRecipient, decryptedGroup, decryptedChange, signedGroupChange);
|
||||
}
|
||||
}
|
||||
|
||||
private GroupManager.GroupActionResult fetchGroupStateAndSendUpdate(@NonNull Recipient groupRecipient,
|
||||
@NonNull DecryptedGroup decryptedGroup,
|
||||
@NonNull DecryptedGroupChange decryptedChange,
|
||||
@NonNull GroupChange signedGroupChange)
|
||||
throws GroupChangeFailedException, IOException
|
||||
{
|
||||
try {
|
||||
new GroupsV2StateProcessor(context).forGroup(groupMasterKey)
|
||||
.updateLocalGroupToRevision(decryptedChange.getRevision(),
|
||||
System.currentTimeMillis(),
|
||||
decryptedChange);
|
||||
|
||||
RecipientAndThread recipientAndThread = sendGroupUpdate(groupMasterKey, decryptedGroup, decryptedChange, signedGroupChange);
|
||||
|
||||
return new GroupManager.GroupActionResult(groupRecipient,
|
||||
recipientAndThread.threadId,
|
||||
1,
|
||||
Collections.emptyList());
|
||||
} catch (GroupNotAMemberException e) {
|
||||
Log.w(TAG, "Despite adding self to group, server says we are not a member, scheduling refresh of group info " + groupId, e);
|
||||
|
||||
ApplicationDependencies.getJobManager()
|
||||
.add(new RequestGroupV2InfoJob(groupId));
|
||||
|
||||
throw new GroupChangeFailedException(e);
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "Group data fetch failed, scheduling refresh of group info " + groupId, e);
|
||||
|
||||
ApplicationDependencies.getJobManager()
|
||||
.add(new RequestGroupV2InfoJob(groupId));
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private @NonNull DecryptedGroupChange decryptChange(@NonNull GroupChange signedGroupChange)
|
||||
throws GroupChangeFailedException
|
||||
{
|
||||
try {
|
||||
return groupOperations.decryptChange(signedGroupChange, false).get();
|
||||
} catch (VerificationFailedException | InvalidGroupStateException | InvalidProtocolBufferException e) {
|
||||
Log.w(TAG, e);
|
||||
throw new GroupChangeFailedException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a local group from what we know before joining.
|
||||
* <p>
|
||||
* Creates as a {@link GroupsV2StateProcessor#PLACEHOLDER_REVISION} so that we know not do do a
|
||||
* full diff against this group once we learn more about this group as that would create a large
|
||||
* update message.
|
||||
*/
|
||||
private DecryptedGroup createPlaceholderGroup(@NonNull DecryptedGroupJoinInfo joinInfo, boolean requestToJoin) {
|
||||
DecryptedGroup.Builder group = DecryptedGroup.newBuilder()
|
||||
.setTitle(joinInfo.getTitle())
|
||||
.setAvatar(joinInfo.getAvatar())
|
||||
.setRevision(GroupsV2StateProcessor.PLACEHOLDER_REVISION);
|
||||
|
||||
Recipient self = Recipient.self();
|
||||
ByteString selfUuid = UuidUtil.toByteString(self.requireUuid());
|
||||
ByteString profileKey = ByteString.copyFrom(Objects.requireNonNull(self.getProfileKey()));
|
||||
|
||||
if (requestToJoin) {
|
||||
group.addRequestingMembers(DecryptedRequestingMember.newBuilder()
|
||||
.setUuid(selfUuid)
|
||||
.setProfileKey(profileKey));
|
||||
} else {
|
||||
group.addMembers(DecryptedMember.newBuilder()
|
||||
.setUuid(selfUuid)
|
||||
.setProfileKey(profileKey));
|
||||
}
|
||||
|
||||
return group.build();
|
||||
}
|
||||
|
||||
private @NonNull GroupChange joinGroupOnServer(boolean requestToJoin, int currentRevision)
|
||||
throws GroupChangeFailedException, IOException, MembershipNotSuitableForV2Exception, GroupLinkNotActiveException, GroupJoinAlreadyAMemberException
|
||||
{
|
||||
if (!GroupsV2CapabilityChecker.allAndSelfSupportGroupsV2AndUuid(Collections.singleton(Recipient.self().getId()))) {
|
||||
throw new MembershipNotSuitableForV2Exception("Self does not support GV2 or UUID capabilities");
|
||||
}
|
||||
|
||||
GroupCandidate self = groupCandidateHelper.recipientIdToCandidate(Recipient.self().getId());
|
||||
|
||||
if (!self.hasProfileKeyCredential()) {
|
||||
throw new MembershipNotSuitableForV2Exception("No profile key credential for self");
|
||||
}
|
||||
|
||||
ProfileKeyCredential profileKeyCredential = self.getProfileKeyCredential().get();
|
||||
|
||||
GroupChange.Actions.Builder change = requestToJoin ? groupOperations.createGroupJoinRequest(profileKeyCredential)
|
||||
: groupOperations.createGroupJoinDirect(profileKeyCredential);
|
||||
|
||||
change.setSourceUuid(UuidUtil.toByteString(Recipient.self().getUuid().get()));
|
||||
|
||||
return commitJoinChangeWithConflictResolution(currentRevision, change);
|
||||
}
|
||||
|
||||
private @NonNull GroupChange commitJoinChangeWithConflictResolution(int currentRevision, @NonNull GroupChange.Actions.Builder change)
|
||||
throws GroupChangeFailedException, IOException, GroupLinkNotActiveException, GroupJoinAlreadyAMemberException
|
||||
{
|
||||
for (int attempt = 0; attempt < 5; attempt++) {
|
||||
try {
|
||||
GroupChange.Actions changeActions = change.setRevision(currentRevision + 1)
|
||||
.build();
|
||||
|
||||
Log.i(TAG, "Trying to join group at V" + changeActions.getRevision());
|
||||
GroupChange signedGroupChange = commitJoinToServer(changeActions);
|
||||
|
||||
Log.i(TAG, "Successfully joined group at V" + changeActions.getRevision());
|
||||
return signedGroupChange;
|
||||
} catch (GroupPatchNotAcceptedException e) {
|
||||
Log.w(TAG, "Patch not accepted", e);
|
||||
|
||||
try {
|
||||
if (alreadyPendingAdminApproval() || testGroupMembership()) {
|
||||
throw new GroupJoinAlreadyAMemberException(e);
|
||||
} else {
|
||||
throw new GroupChangeFailedException(e);
|
||||
}
|
||||
} catch (VerificationFailedException | InvalidGroupStateException ex) {
|
||||
throw new GroupChangeFailedException(ex);
|
||||
}
|
||||
} catch (ConflictException e) {
|
||||
Log.w(TAG, "Revision conflict", e);
|
||||
|
||||
currentRevision = getCurrentGroupRevisionFromServer();
|
||||
}
|
||||
}
|
||||
|
||||
throw new GroupChangeFailedException("Unable to join group after conflicts");
|
||||
}
|
||||
|
||||
private @NonNull GroupChange commitJoinToServer(@NonNull GroupChange.Actions change)
|
||||
throws GroupChangeFailedException, IOException, GroupLinkNotActiveException
|
||||
{
|
||||
try {
|
||||
return groupsV2Api.patchGroup(change, authorization.getAuthorizationForToday(selfUuid, groupSecretParams), Optional.fromNullable(password).transform(GroupLinkPassword::serialize));
|
||||
} catch (NotInGroupException | VerificationFailedException e) {
|
||||
Log.w(TAG, e);
|
||||
throw new GroupChangeFailedException(e);
|
||||
} catch (AuthorizationFailedException e) {
|
||||
Log.w(TAG, e);
|
||||
throw new GroupLinkNotActiveException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private int getCurrentGroupRevisionFromServer()
|
||||
throws IOException, GroupLinkNotActiveException, GroupChangeFailedException
|
||||
{
|
||||
try {
|
||||
int currentRevision = getGroupJoinInfoFromServer(groupMasterKey, password).getRevision();
|
||||
|
||||
Log.i(TAG, "Server now on V" + currentRevision);
|
||||
|
||||
return currentRevision;
|
||||
} catch (VerificationFailedException ex) {
|
||||
throw new GroupChangeFailedException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean alreadyPendingAdminApproval()
|
||||
throws IOException, GroupLinkNotActiveException, GroupChangeFailedException
|
||||
{
|
||||
try {
|
||||
boolean pendingAdminApproval = getGroupJoinInfoFromServer(groupMasterKey, password).getPendingAdminApproval();
|
||||
|
||||
if (pendingAdminApproval) {
|
||||
Log.i(TAG, "User is already pending admin approval");
|
||||
}
|
||||
|
||||
return pendingAdminApproval;
|
||||
} catch (VerificationFailedException ex) {
|
||||
throw new GroupChangeFailedException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean testGroupMembership()
|
||||
throws IOException, VerificationFailedException, InvalidGroupStateException
|
||||
{
|
||||
try {
|
||||
groupsV2Api.getGroup(groupSecretParams, authorization.getAuthorizationForToday(Recipient.self().requireUuid(), groupSecretParams));
|
||||
return true;
|
||||
} catch (NotInGroupException ex) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
void cancelJoinRequest()
|
||||
throws GroupChangeFailedException, IOException
|
||||
{
|
||||
Set<UUID> uuids = Collections.singleton(Recipient.self().getUuid().get());
|
||||
|
||||
GroupChange signedGroupChange;
|
||||
try {
|
||||
signedGroupChange = commitCancelChangeWithConflictResolution(groupOperations.createRefuseGroupJoinRequest(uuids));
|
||||
} catch (GroupLinkNotActiveException e) {
|
||||
Log.d(TAG, "Unexpected unable to leave group due to group link off");
|
||||
throw new GroupChangeFailedException(e);
|
||||
}
|
||||
|
||||
DecryptedGroup decryptedGroup = groupDatabase.requireGroup(groupId).requireV2GroupProperties().getDecryptedGroup();
|
||||
|
||||
try {
|
||||
DecryptedGroupChange decryptedChange = groupOperations.decryptChange(signedGroupChange, false).get();
|
||||
DecryptedGroup newGroup = DecryptedGroupUtil.applyWithoutRevisionCheck(decryptedGroup, decryptedChange);
|
||||
|
||||
groupDatabase.update(groupId, resetRevision(newGroup, decryptedGroup.getRevision()));
|
||||
|
||||
sendGroupUpdate(groupMasterKey, decryptedGroup, decryptedChange, signedGroupChange);
|
||||
} catch (VerificationFailedException | InvalidGroupStateException | NotAbleToApplyGroupV2ChangeException e) {
|
||||
throw new GroupChangeFailedException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private DecryptedGroup resetRevision(DecryptedGroup newGroup, int revision) {
|
||||
return DecryptedGroup.newBuilder(newGroup)
|
||||
.setRevision(revision)
|
||||
.build();
|
||||
}
|
||||
|
||||
private @NonNull GroupChange commitCancelChangeWithConflictResolution(@NonNull GroupChange.Actions.Builder change)
|
||||
throws GroupChangeFailedException, IOException, GroupLinkNotActiveException
|
||||
{
|
||||
int currentRevision = getCurrentGroupRevisionFromServer();
|
||||
|
||||
for (int attempt = 0; attempt < 5; attempt++) {
|
||||
try {
|
||||
GroupChange.Actions changeActions = change.setRevision(currentRevision + 1)
|
||||
.build();
|
||||
|
||||
Log.i(TAG, "Trying to cancel request group at V" + changeActions.getRevision());
|
||||
GroupChange signedGroupChange = commitJoinToServer(changeActions);
|
||||
|
||||
Log.i(TAG, "Successfully cancelled group join at V" + changeActions.getRevision());
|
||||
return signedGroupChange;
|
||||
} catch (GroupPatchNotAcceptedException e) {
|
||||
throw new GroupChangeFailedException(e);
|
||||
} catch (ConflictException e) {
|
||||
Log.w(TAG, "Revision conflict", e);
|
||||
|
||||
currentRevision = getCurrentGroupRevisionFromServer();
|
||||
}
|
||||
}
|
||||
|
||||
throw new GroupChangeFailedException("Unable to cancel group join request after conflicts");
|
||||
}
|
||||
}
|
||||
|
||||
private abstract static class LockOwner implements Closeable {
|
||||
final Closeable lock;
|
||||
|
||||
LockOwner(@NonNull Closeable lock) {
|
||||
this.lock = lock;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
|
@@ -1,6 +1,6 @@
|
||||
package org.thoughtcrime.securesms.groups.ui;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.StringRes;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
@@ -9,7 +9,11 @@ public final class GroupErrors {
|
||||
private GroupErrors() {
|
||||
}
|
||||
|
||||
public static @StringRes int getUserDisplayMessage(@NonNull GroupChangeFailureReason failureReason) {
|
||||
public static @StringRes int getUserDisplayMessage(@Nullable GroupChangeFailureReason failureReason) {
|
||||
if (failureReason == null) {
|
||||
return R.string.ManageGroupActivity_failed_to_update_the_group;
|
||||
}
|
||||
|
||||
switch (failureReason) {
|
||||
case NO_RIGHTS : return R.string.ManageGroupActivity_you_dont_have_the_rights_to_do_this;
|
||||
case NOT_CAPABLE : return R.string.ManageGroupActivity_not_capable;
|
||||
|
@@ -1,42 +1,39 @@
|
||||
package org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.signal.storageservice.protos.groups.AccessControl;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroupJoinInfo;
|
||||
|
||||
public final class GroupDetails {
|
||||
private final String groupName;
|
||||
private final 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;
|
||||
}
|
||||
}
|
||||
|
@@ -1,5 +1,6 @@
|
||||
package org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
@@ -22,7 +23,9 @@ import org.thoughtcrime.securesms.color.MaterialColor;
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView;
|
||||
import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto;
|
||||
import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationActivity;
|
||||
import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
@@ -31,6 +34,8 @@ import org.thoughtcrime.securesms.util.ThemeUtil;
|
||||
|
||||
public final class GroupJoinBottomSheetDialogFragment extends BottomSheetDialogFragment {
|
||||
|
||||
private static final String TAG = Log.tag(GroupJoinUpdateRequiredBottomSheetDialogFragment.class);
|
||||
|
||||
private static final String ARG_GROUP_INVITE_LINK_URL = "group_invite_url";
|
||||
|
||||
private ProgressBar busy;
|
||||
@@ -93,14 +98,13 @@ public final class GroupJoinBottomSheetDialogFragment extends BottomSheetDialogF
|
||||
groupName.setText(details.getGroupName());
|
||||
groupDetails.setText(requireContext().getResources().getQuantityString(R.plurals.GroupJoinBottomSheetDialogFragment_group_dot_d_members, details.getGroupMembershipCount(), details.getGroupMembershipCount()));
|
||||
|
||||
switch (FeatureFlags.clientLocalGroupJoinStatus()) {
|
||||
switch (getGroupJoinStatus()) {
|
||||
case COMING_SOON:
|
||||
groupJoinExplain.setText(R.string.GroupJoinUpdateRequiredBottomSheetDialogFragment_coming_soon);
|
||||
groupCancelButton.setText(android.R.string.ok);
|
||||
groupJoinButton.setVisibility(View.GONE);
|
||||
break;
|
||||
case UPDATE_TO_JOIN:
|
||||
case LOCAL_CAN_JOIN:
|
||||
groupJoinExplain.setText(R.string.GroupJoinUpdateRequiredBottomSheetDialogFragment_update_message);
|
||||
groupJoinButton.setText(R.string.GroupJoinUpdateRequiredBottomSheetDialogFragment_update_signal);
|
||||
groupJoinButton.setOnClickListener(v -> {
|
||||
@@ -109,6 +113,17 @@ public final class GroupJoinBottomSheetDialogFragment extends BottomSheetDialogF
|
||||
});
|
||||
groupJoinButton.setVisibility(View.VISIBLE);
|
||||
break;
|
||||
case LOCAL_CAN_JOIN:
|
||||
groupJoinExplain.setText(details.joinRequiresAdminApproval() ? R.string.GroupJoinBottomSheetDialogFragment_admin_approval_needed
|
||||
: R.string.GroupJoinBottomSheetDialogFragment_direct_join);
|
||||
groupJoinButton.setText(details.joinRequiresAdminApproval() ? R.string.GroupJoinBottomSheetDialogFragment_request_to_join
|
||||
: R.string.GroupJoinBottomSheetDialogFragment_join);
|
||||
groupJoinButton.setOnClickListener(v -> {
|
||||
Log.i(TAG, details.joinRequiresAdminApproval() ? "Attempting to direct join group" : "Attempting to request to join group");
|
||||
viewModel.join(details);
|
||||
});
|
||||
groupJoinButton.setVisibility(View.VISIBLE);
|
||||
break;
|
||||
}
|
||||
|
||||
avatar.setImageBytesForGroup(details.getAvatarBytes(), new FallbackPhotoProvider(), MaterialColor.STEEL);
|
||||
@@ -117,19 +132,55 @@ public final class GroupJoinBottomSheetDialogFragment extends BottomSheetDialogF
|
||||
});
|
||||
|
||||
viewModel.isBusy().observe(getViewLifecycleOwner(), isBusy -> busy.setVisibility(isBusy ? View.VISIBLE : View.GONE));
|
||||
|
||||
viewModel.getErrors().observe(getViewLifecycleOwner(), error -> {
|
||||
Toast.makeText(requireContext(), errorToMessage(error), Toast.LENGTH_SHORT).show();
|
||||
dismiss();
|
||||
});
|
||||
|
||||
viewModel.getJoinErrors().observe(getViewLifecycleOwner(), error -> Toast.makeText(requireContext(), errorToMessage(error), Toast.LENGTH_SHORT).show());
|
||||
|
||||
viewModel.getJoinSuccess().observe(getViewLifecycleOwner(), joinGroupSuccess -> {
|
||||
Log.i(TAG, "Group joined, navigating to group");
|
||||
|
||||
Intent intent = ConversationActivity.buildIntent(requireContext(), joinGroupSuccess.getGroupRecipient().getId(), joinGroupSuccess.getGroupThreadId());
|
||||
requireActivity().startActivity(intent);
|
||||
|
||||
dismiss();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
protected @NonNull String errorToMessage(FetchGroupDetailsError error) {
|
||||
private static FeatureFlags.GroupJoinStatus getGroupJoinStatus() {
|
||||
FeatureFlags.GroupJoinStatus groupJoinStatus = FeatureFlags.clientLocalGroupJoinStatus();
|
||||
|
||||
if (groupJoinStatus == FeatureFlags.GroupJoinStatus.LOCAL_CAN_JOIN) {
|
||||
if (!FeatureFlags.groupsV2() || Recipient.self().getGroupsV2Capability() == Recipient.Capability.NOT_SUPPORTED) {
|
||||
// TODO [Alan] GV2 additional copy could be presented in these cases
|
||||
return FeatureFlags.GroupJoinStatus.UPDATE_TO_JOIN;
|
||||
}
|
||||
|
||||
return groupJoinStatus;
|
||||
}
|
||||
|
||||
return groupJoinStatus;
|
||||
}
|
||||
|
||||
private @NonNull String errorToMessage(@NonNull FetchGroupDetailsError error) {
|
||||
if (error == FetchGroupDetailsError.GroupLinkNotActive) {
|
||||
return getString(R.string.GroupJoinBottomSheetDialogFragment_this_group_link_is_not_active);
|
||||
}
|
||||
return getString(R.string.GroupJoinBottomSheetDialogFragment_unable_to_get_group_information_please_try_again_later);
|
||||
}
|
||||
|
||||
private @NonNull String errorToMessage(@NonNull JoinGroupError error) {
|
||||
switch (error) {
|
||||
case GROUP_LINK_NOT_ACTIVE: return getString(R.string.GroupJoinBottomSheetDialogFragment_this_group_link_is_not_active);
|
||||
case NETWORK_ERROR : return getString(R.string.GroupJoinBottomSheetDialogFragment_encountered_a_network_error);
|
||||
default : return getString(R.string.GroupJoinBottomSheetDialogFragment_unable_to_join_group_please_try_again_later);
|
||||
}
|
||||
}
|
||||
|
||||
private GroupInviteLinkUrl getGroupInviteLinkUrl() {
|
||||
try {
|
||||
//noinspection ConstantConditions
|
||||
|
@@ -5,15 +5,17 @@ import android.content.Context;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
import androidx.core.util.Consumer;
|
||||
|
||||
import org.signal.storageservice.protos.groups.AccessControl;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroupJoinInfo;
|
||||
import org.signal.zkgroup.VerificationFailedException;
|
||||
import org.thoughtcrime.securesms.groups.GroupChangeBusyException;
|
||||
import org.thoughtcrime.securesms.groups.GroupChangeFailedException;
|
||||
import org.thoughtcrime.securesms.groups.GroupManager;
|
||||
import org.thoughtcrime.securesms.groups.MembershipNotSuitableForV2Exception;
|
||||
import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl;
|
||||
import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.util.AsynchronousCallback;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException;
|
||||
|
||||
@@ -31,7 +33,7 @@ final class GroupJoinRepository {
|
||||
this.groupInviteLinkUrl = groupInviteLinkUrl;
|
||||
}
|
||||
|
||||
void getGroupDetails(@NonNull GetGroupDetailsCallback callback) {
|
||||
void getGroupDetails(@NonNull AsynchronousCallback.WorkerThread<GroupDetails, FetchGroupDetailsError> callback) {
|
||||
SignalExecutors.UNBOUNDED.execute(() -> {
|
||||
try {
|
||||
callback.onComplete(getGroupDetails());
|
||||
@@ -43,6 +45,30 @@ final class GroupJoinRepository {
|
||||
});
|
||||
}
|
||||
|
||||
void joinGroup(@NonNull GroupDetails groupDetails,
|
||||
@NonNull AsynchronousCallback.WorkerThread<JoinGroupSuccess, JoinGroupError> callback)
|
||||
{
|
||||
SignalExecutors.UNBOUNDED.execute(() -> {
|
||||
try {
|
||||
GroupManager.GroupActionResult groupActionResult = GroupManager.joinGroup(context,
|
||||
groupInviteLinkUrl.getGroupMasterKey(),
|
||||
groupInviteLinkUrl.getPassword(),
|
||||
groupDetails.getJoinInfo(),
|
||||
groupDetails.getAvatarBytes());
|
||||
|
||||
callback.onComplete(new JoinGroupSuccess(groupActionResult.getGroupRecipient(), groupActionResult.getThreadId()));
|
||||
} catch (IOException e) {
|
||||
callback.onError(JoinGroupError.NETWORK_ERROR);
|
||||
} catch (GroupChangeBusyException e) {
|
||||
callback.onError(JoinGroupError.BUSY);
|
||||
} catch (GroupLinkNotActiveException e) {
|
||||
callback.onError(JoinGroupError.GROUP_LINK_NOT_ACTIVE);
|
||||
} catch (GroupChangeFailedException | MembershipNotSuitableForV2Exception e) {
|
||||
callback.onError(JoinGroupError.FAILED);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private @NonNull GroupDetails getGroupDetails()
|
||||
throws VerificationFailedException, IOException, GroupLinkNotActiveException
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining;
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MediatorLiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
@@ -10,35 +11,62 @@ import androidx.lifecycle.ViewModel;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
|
||||
import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl;
|
||||
import org.thoughtcrime.securesms.util.AsynchronousCallback;
|
||||
import org.thoughtcrime.securesms.util.SingleLiveEvent;
|
||||
|
||||
public class GroupJoinViewModel extends ViewModel {
|
||||
|
||||
private final GroupJoinRepository repository;
|
||||
private final MutableLiveData<GroupDetails> groupDetails = new MutableLiveData<>();
|
||||
private final MutableLiveData<FetchGroupDetailsError> errors = new SingleLiveEvent<>();
|
||||
private final MutableLiveData<JoinGroupError> joinErrors = new SingleLiveEvent<>();
|
||||
private final MutableLiveData<Boolean> busy = new MediatorLiveData<>();
|
||||
private final MutableLiveData<JoinGroupSuccess> joinSuccess = new SingleLiveEvent<>();
|
||||
|
||||
private GroupJoinViewModel(@NonNull GroupJoinRepository repository) {
|
||||
this.repository = repository;
|
||||
|
||||
busy.setValue(true);
|
||||
repository.getGroupDetails(new GroupJoinRepository.GetGroupDetailsCallback() {
|
||||
repository.getGroupDetails(new AsynchronousCallback.WorkerThread<GroupDetails, FetchGroupDetailsError>() {
|
||||
@Override
|
||||
public void onComplete(@NonNull GroupDetails details) {
|
||||
public void onComplete(@Nullable GroupDetails details) {
|
||||
busy.postValue(false);
|
||||
groupDetails.postValue(details);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(@NonNull FetchGroupDetailsError error) {
|
||||
public void onError(@Nullable FetchGroupDetailsError error) {
|
||||
busy.postValue(false);
|
||||
errors.postValue(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void join(@NonNull GroupDetails groupDetails) {
|
||||
busy.setValue(true);
|
||||
repository.joinGroup(groupDetails, new AsynchronousCallback.WorkerThread<JoinGroupSuccess, JoinGroupError>() {
|
||||
@Override
|
||||
public void onComplete(@Nullable JoinGroupSuccess result) {
|
||||
busy.postValue(false);
|
||||
joinSuccess.postValue(result);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(@Nullable JoinGroupError error) {
|
||||
busy.postValue(false);
|
||||
joinErrors.postValue(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
LiveData<GroupDetails> getGroupDetails() {
|
||||
return groupDetails;
|
||||
}
|
||||
|
||||
LiveData<JoinGroupSuccess> getJoinSuccess() {
|
||||
return joinSuccess;
|
||||
}
|
||||
|
||||
LiveData<Boolean> isBusy() {
|
||||
return busy;
|
||||
}
|
||||
@@ -47,6 +75,10 @@ public class GroupJoinViewModel extends ViewModel {
|
||||
return errors;
|
||||
}
|
||||
|
||||
LiveData<JoinGroupError> getJoinErrors() {
|
||||
return joinErrors;
|
||||
}
|
||||
|
||||
public static class Factory implements ViewModelProvider.Factory {
|
||||
|
||||
private final Context context;
|
||||
|
@@ -0,0 +1,8 @@
|
||||
package org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining;
|
||||
|
||||
enum JoinGroupError {
|
||||
BUSY,
|
||||
GROUP_LINK_NOT_ACTIVE,
|
||||
FAILED,
|
||||
NETWORK_ERROR,
|
||||
}
|
@@ -0,0 +1,21 @@
|
||||
package org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining;
|
||||
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
|
||||
final class JoinGroupSuccess {
|
||||
private final Recipient groupRecipient;
|
||||
private final long groupThreadId;
|
||||
|
||||
JoinGroupSuccess(Recipient groupRecipient, long groupThreadId) {
|
||||
this.groupRecipient = groupRecipient;
|
||||
this.groupThreadId = groupThreadId;
|
||||
}
|
||||
|
||||
Recipient getGroupRecipient() {
|
||||
return groupRecipient;
|
||||
}
|
||||
|
||||
long getGroupThreadId() {
|
||||
return groupThreadId;
|
||||
}
|
||||
}
|
@@ -1,25 +1,28 @@
|
||||
package org.thoughtcrime.securesms.groups.v2.processing;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil;
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupChangeReconstruct;
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupChangeUtil;
|
||||
import org.whispersystems.signalservice.api.groupsv2.NotAbleToApplyGroupV2ChangeException;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.Objects;
|
||||
import java.util.List;
|
||||
|
||||
final class GroupStateMapper {
|
||||
|
||||
private static final String TAG = Log.tag(GroupStateMapper.class);
|
||||
|
||||
static final int LATEST = Integer.MAX_VALUE;
|
||||
static final int 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());
|
||||
HashMap<Integer, ServerGroupLogEntry> statesToApplyNow = new HashMap<>(inputState.getServerHistory().size());
|
||||
ArrayList<ServerGroupLogEntry> 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<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);
|
||||
}
|
||||
|
||||
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<StateChain.Pair<DecryptedGroup, DecryptedGroupChange>> mapperList = stateChain.getList();
|
||||
List<LocalGroupLogEntry> appliedChanges = new ArrayList<>(mapperList.size());
|
||||
|
||||
for (StateChain.Pair<DecryptedGroup, DecryptedGroupChange> entry : mapperList) {
|
||||
if (current == null || entry.getDelta() != null) {
|
||||
appliedChanges.add(new LocalGroupLogEntry(entry.getState(), entry.getDelta()));
|
||||
}
|
||||
}
|
||||
|
||||
return new AdvanceGroupStateResult(appliedChanges, new GlobalGroupState(stateChain.getLatestState(), statesToApplyLater));
|
||||
}
|
||||
|
||||
private static AdvanceGroupStateResult cleanDuplicatedChanges(@NonNull AdvanceGroupStateResult groupStateResult,
|
||||
@Nullable DecryptedGroup previousGroupState)
|
||||
{
|
||||
if (previousGroupState == null) return groupStateResult;
|
||||
|
||||
ArrayList<LocalGroupLogEntry> appliedChanges = new ArrayList<>(groupStateResult.getProcessedLogEntries().size());
|
||||
|
||||
for (LocalGroupLogEntry entry : groupStateResult.getProcessedLogEntries()) {
|
||||
DecryptedGroupChange change = entry.getChange();
|
||||
|
||||
if (change != null) {
|
||||
change = GroupChangeUtil.resolveConflict(previousGroupState, change).build();
|
||||
}
|
||||
|
||||
appliedChanges.add(new LocalGroupLogEntry(entry.getGroup(), change));
|
||||
|
||||
previousGroupState = entry.getGroup();
|
||||
}
|
||||
|
||||
return new AdvanceGroupStateResult(appliedChanges, groupStateResult.getNewGlobalGroupState());
|
||||
}
|
||||
|
||||
private static StateChain<DecryptedGroup, DecryptedGroupChange> createNewMapper() {
|
||||
return new StateChain<>(
|
||||
(group, change) -> {
|
||||
try {
|
||||
return DecryptedGroupUtil.applyWithoutRevisionCheck(group, change);
|
||||
} catch (NotAbleToApplyGroupV2ChangeException e) {
|
||||
Log.w(TAG, "Unable to apply V" + change.getRevision(), e);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
(groupB, groupA) -> GroupChangeReconstruct.reconstructGroupChange(groupA, groupB),
|
||||
(groupA, groupB) -> DecryptedGroupUtil.changeIsEmpty(GroupChangeReconstruct.reconstructGroupChange(groupA, groupB))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -17,9 +17,7 @@ import org.signal.zkgroup.groups.GroupSecretParams;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.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");
|
||||
|
@@ -0,0 +1,172 @@
|
||||
package org.thoughtcrime.securesms.groups.v2.processing;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Maintains a chain of state pairs:
|
||||
* <pre>
|
||||
* {@code
|
||||
* (S1, Delta1),
|
||||
* (S2, Delta2),
|
||||
* (S3, Delta3)
|
||||
* }
|
||||
* </pre>
|
||||
* Such that the states always include all deltas.
|
||||
* <pre>
|
||||
* {@code
|
||||
* (S1, _),
|
||||
* (S1 + Delta2, Delta2),
|
||||
* (S1 + Delta2 + Delta3, Delta3),
|
||||
* }
|
||||
* </pre>
|
||||
* <p>
|
||||
* If a pushed delta does not correct create the new state (tested by {@link StateEquality}), a new
|
||||
* delta and state is inserted like so:
|
||||
* <pre>
|
||||
* {@code
|
||||
* (PreviousState, PreviousDelta),
|
||||
* (PreviousState + NewDelta, NewDelta),
|
||||
* (NewState, PreviousState + NewDelta - NewState),
|
||||
* }
|
||||
* </pre>
|
||||
* That is it keeps both the newly supplied delta and state, but creates an interim state and delta.
|
||||
*
|
||||
* The + function is supplied by {@link AddDelta} and the - function is supplied by {@link SubtractStates}.
|
||||
*/
|
||||
public final class StateChain<State, Delta> {
|
||||
|
||||
private final AddDelta<State, Delta> add;
|
||||
private final SubtractStates<State, Delta> subtract;
|
||||
private final StateEquality<State> stateEquality;
|
||||
|
||||
private final List<Pair<State, Delta>> pairs = new LinkedList<>();
|
||||
|
||||
public StateChain(@NonNull AddDelta<State, Delta> add,
|
||||
@NonNull SubtractStates<State, Delta> subtract,
|
||||
@NonNull StateEquality<State> stateEquality)
|
||||
{
|
||||
this.add = add;
|
||||
this.subtract = subtract;
|
||||
this.stateEquality = stateEquality;
|
||||
}
|
||||
|
||||
public void push(@Nullable State state, @Nullable Delta delta) {
|
||||
if (delta == null && state == null) return;
|
||||
|
||||
boolean bothSupplied = state != null && delta != null;
|
||||
State latestState = getLatestState();
|
||||
|
||||
if (latestState == null && state == null) return;
|
||||
|
||||
if (latestState != null) {
|
||||
if (delta == null) {
|
||||
|
||||
delta = subtract.subtract(state, latestState);
|
||||
}
|
||||
|
||||
if (state == null) {
|
||||
state = add.add(latestState, delta);
|
||||
|
||||
if (state == null) return;
|
||||
}
|
||||
|
||||
if (bothSupplied) {
|
||||
State calculatedState = add.add(latestState, delta);
|
||||
|
||||
if (calculatedState == null) {
|
||||
push(state, null);
|
||||
return;
|
||||
} else if (!stateEquality.equals(state, calculatedState)) {
|
||||
push(null, delta);
|
||||
push(state, null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (latestState == null || !stateEquality.equals(latestState, state)) {
|
||||
pairs.add(new Pair<>(state, delta));
|
||||
}
|
||||
}
|
||||
|
||||
public @Nullable State getLatestState() {
|
||||
int size = pairs.size();
|
||||
|
||||
return size == 0 ? null : pairs.get(size - 1).getState();
|
||||
}
|
||||
|
||||
public List<Pair<State, Delta>> getList() {
|
||||
return new ArrayList<>(pairs);
|
||||
}
|
||||
|
||||
public static final class Pair<State, Delta> {
|
||||
@NonNull private final State state;
|
||||
@Nullable private final Delta delta;
|
||||
|
||||
Pair(@NonNull State state, @Nullable Delta delta) {
|
||||
this.state = state;
|
||||
this.delta = delta;
|
||||
}
|
||||
|
||||
public @NonNull State getState() {
|
||||
return state;
|
||||
}
|
||||
|
||||
public @Nullable Delta getDelta() {
|
||||
return delta;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return String.format("(%s, %s)", state, delta);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
|
||||
Pair<?, ?> other = (Pair<?, ?>) o;
|
||||
|
||||
return state.equals(other.state) &&
|
||||
Objects.equals(delta, other.delta);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
int result = state.hashCode();
|
||||
result = 31 * result + (delta != null ? delta.hashCode() : 0);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
interface AddDelta<State, Delta> {
|
||||
|
||||
/**
|
||||
* Add {@param delta} to {@param state} and return the new {@link State}.
|
||||
* <p>
|
||||
* If this returns null, then the delta could not be applied and will be ignored.
|
||||
*/
|
||||
@Nullable State add(@NonNull State state, @NonNull Delta delta);
|
||||
}
|
||||
|
||||
interface SubtractStates<State, Delta> {
|
||||
|
||||
/**
|
||||
* Finds a delta = {@param stateB} - {@param stateA}
|
||||
* such that {@param stateA} + {@link Delta} = {@param stateB}.
|
||||
*/
|
||||
@NonNull Delta subtract(@NonNull State stateB, @NonNull State stateA);
|
||||
}
|
||||
|
||||
interface StateEquality<State> {
|
||||
|
||||
boolean equals(@NonNull State stateA, @NonNull State stateB);
|
||||
}
|
||||
}
|
@@ -28,7 +28,6 @@ import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
|
||||
import org.thoughtcrime.securesms.crypto.SecurityEvent;
|
||||
import org.thoughtcrime.securesms.crypto.storage.TextSecureSessionStore;
|
||||
import org.thoughtcrime.securesms.database.AttachmentDatabase;
|
||||
import org.thoughtcrime.securesms.database.Database;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase;
|
||||
import org.thoughtcrime.securesms.database.GroupReceiptDatabase;
|
||||
@@ -36,10 +35,8 @@ import org.thoughtcrime.securesms.database.GroupReceiptDatabase.GroupReceiptInfo
|
||||
import org.thoughtcrime.securesms.database.MessageDatabase;
|
||||
import org.thoughtcrime.securesms.database.MessageDatabase.InsertResult;
|
||||
import org.thoughtcrime.securesms.database.MessageDatabase.SyncMessageId;
|
||||
import org.thoughtcrime.securesms.database.MmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.MmsSmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
||||
import org.thoughtcrime.securesms.database.SmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.StickerDatabase;
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.Mention;
|
||||
@@ -350,16 +347,16 @@ public final class PushProcessMessageJob extends BaseJob {
|
||||
|
||||
if (isGv2Message) {
|
||||
GroupMasterKey groupMasterKey = message.getGroupContext().get().getGroupV2().get().getMasterKey();
|
||||
GroupId.V2 groupIdV2 = groupId.get().requireV2();
|
||||
|
||||
if (!groupV2PreProcessMessage(content, groupMasterKey, message.getGroupContext().get().getGroupV2().get())) {
|
||||
Log.i(TAG, "Ignoring GV2 message for group we are not currently in " + groupId);
|
||||
Log.i(TAG, "Ignoring GV2 message for group we are not currently in " + groupIdV2);
|
||||
return;
|
||||
}
|
||||
|
||||
GroupId.V2 groupIdV2 = groupId.get().requireV2();
|
||||
Recipient sender = Recipient.externalPush(context, content.getSender());
|
||||
Recipient sender = Recipient.externalPush(context, content.getSender());
|
||||
if (!groupDatabase.isCurrentMember(groupIdV2, sender.getId())) {
|
||||
Log.i(TAG, "Ignoring GV2 message from member not in group " + groupId);
|
||||
Log.i(TAG, "Ignoring GV2 message from member not in group " + groupIdV2);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
@@ -7,7 +7,6 @@ import org.signal.zkgroup.groups.GroupMasterKey;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase;
|
||||
import org.thoughtcrime.securesms.database.MessageDatabase;
|
||||
import org.thoughtcrime.securesms.database.MmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context;
|
||||
import org.thoughtcrime.securesms.groups.GroupChangeBusyException;
|
||||
import org.thoughtcrime.securesms.groups.GroupId;
|
||||
@@ -22,7 +21,6 @@ import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.mms.MmsException;
|
||||
import org.thoughtcrime.securesms.mms.OutgoingGroupUpdateMessage;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.Hex;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.groupsv2.NoCredentialForRedemptionTimeException;
|
||||
@@ -79,11 +77,12 @@ public final class WakeGroupV2Job extends BaseJob {
|
||||
GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context);
|
||||
GroupId.V2 groupId = GroupId.v2(groupMasterKey);
|
||||
|
||||
if (!groupDatabase.getGroup(groupId).isPresent()) {
|
||||
if (groupDatabase.findGroup(groupId)) {
|
||||
Log.w(TAG, "Group already exists " + groupId);
|
||||
return;
|
||||
} else {
|
||||
GroupManager.updateGroupFromServer(context, groupMasterKey, GroupsV2StateProcessor.LATEST, System.currentTimeMillis(), null);
|
||||
Log.i(TAG, "Group created " + groupId);
|
||||
} else {
|
||||
Log.w(TAG, "Group already exists " + groupId);
|
||||
}
|
||||
|
||||
Optional<GroupDatabase.GroupRecord> group = groupDatabase.getGroup(groupId);
|
||||
|
@@ -0,0 +1,64 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
public final class AsynchronousCallback {
|
||||
|
||||
/**
|
||||
* Use to call back from a asynchronous repository call, e.g. a load operation.
|
||||
* <p>
|
||||
* Using the original thread used for operation to invoke the callback methods.
|
||||
* <p>
|
||||
* The contract is that exactly one method on the callback will be called, exactly once.
|
||||
*
|
||||
* @param <R> Result type
|
||||
* @param <E> Error type
|
||||
*/
|
||||
public interface WorkerThread<R, E> {
|
||||
|
||||
@androidx.annotation.WorkerThread
|
||||
void onComplete(@Nullable R result);
|
||||
|
||||
@androidx.annotation.WorkerThread
|
||||
void onError(@Nullable E error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Use to call back from a asynchronous repository call, e.g. a load operation.
|
||||
* <p>
|
||||
* Using the main thread used for operation to invoke the callback methods.
|
||||
* <p>
|
||||
* The contract is that exactly one method on the callback will be called, exactly once.
|
||||
*
|
||||
* @param <R> Result type
|
||||
* @param <E> Error type
|
||||
*/
|
||||
public interface MainThread<R, E> {
|
||||
|
||||
@androidx.annotation.MainThread
|
||||
void onComplete(@Nullable R result);
|
||||
|
||||
@androidx.annotation.MainThread
|
||||
void onError(@Nullable E error);
|
||||
|
||||
|
||||
/**
|
||||
* If you have a callback that is only suitable for running on the main thread, this will
|
||||
* decorate it to make it suitable to pass as a worker thread callback.
|
||||
*/
|
||||
default @NonNull WorkerThread<R, E> toWorkerCallback() {
|
||||
return new WorkerThread<R, E>() {
|
||||
@Override
|
||||
public void onComplete(@Nullable R result) {
|
||||
Util.runOnMain(() -> MainThread.this.onComplete(result));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(@Nullable E error) {
|
||||
Util.runOnMain(() -> MainThread.this.onError(error));
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
@@ -112,9 +112,7 @@ public class CommunicationActions {
|
||||
|
||||
@Override
|
||||
protected void onPostExecute(Long threadId) {
|
||||
Intent intent = new Intent(context, ConversationActivity.class);
|
||||
intent.putExtra(ConversationActivity.RECIPIENT_EXTRA, recipient.getId());
|
||||
intent.putExtra(ConversationActivity.THREAD_ID_EXTRA, threadId);
|
||||
Intent intent = ConversationActivity.buildIntent(context, recipient.getId(), threadId);
|
||||
|
||||
if (!TextUtils.isEmpty(text)) {
|
||||
intent.putExtra(ConversationActivity.TEXT_EXTRA, text);
|
||||
|
@@ -1,6 +1,7 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
|
||||
/**
|
||||
* A class that will throttle the number of runnables executed to be at most once every specified
|
||||
@@ -21,7 +22,7 @@ public class Debouncer {
|
||||
* {@code threshold} milliseconds.
|
||||
*/
|
||||
public Debouncer(long threshold) {
|
||||
this.handler = new Handler();
|
||||
this.handler = new Handler(Looper.getMainLooper());
|
||||
this.threshold = threshold;
|
||||
}
|
||||
|
||||
|
@@ -99,6 +99,10 @@
|
||||
layout="@layout/conversation_no_longer_a_member"
|
||||
android:visibility="gone" />
|
||||
|
||||
<include
|
||||
layout="@layout/conversation_requesting_bottom_banner"
|
||||
android:visibility="gone" />
|
||||
|
||||
<include layout="@layout/conversation_input_panel" />
|
||||
|
||||
<include layout="@layout/conversation_search_nav" />
|
||||
|
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/conversation_requesting_banner"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?secondary_background"
|
||||
android:gravity="center"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingEnd="16dp"
|
||||
android:paddingBottom="8dp"
|
||||
android:text="@string/ConversationActivity_your_request_to_join_has_been_sent_to_the_group_admin"
|
||||
android:textColor="?title_text_color_secondary" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/conversation_cancel_request"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:text="@string/ConversationActivity_cancel_request" />
|
||||
|
||||
</LinearLayout>
|
@@ -245,6 +245,8 @@
|
||||
<string name="ConversationActivity_unable_to_record_audio">Unable to record audio!</string>
|
||||
<string name="ConversationActivity_you_cant_send_messages_to_this_group">You can\'t send messages to this group because you\'re no longer a member.</string>
|
||||
<string name="ConversationActivity_there_is_no_app_available_to_handle_this_link_on_your_device">There is no app available to handle this link on your device.</string>
|
||||
<string name="ConversationActivity_your_request_to_join_has_been_sent_to_the_group_admin">Your request to join has been sent to the group admin. You\'ll be notified when they take action.</string>
|
||||
<string name="ConversationActivity_cancel_request">Cancel Request</string>
|
||||
|
||||
<string name="ConversationActivity_to_send_audio_messages_allow_signal_access_to_your_microphone">To send audio messages, allow Signal access to your microphone.</string>
|
||||
<string name="ConversationActivity_signal_requires_the_microphone_permission_in_order_to_send_audio_messages">Signal requires the Microphone permission in order to send audio messages, but it has been permanently denied. Please continue to app settings, select \"Permissions\", and enable \"Microphone\".</string>
|
||||
@@ -657,7 +659,10 @@
|
||||
<string name="GroupJoinBottomSheetDialogFragment_you_are_already_a_member">You are already a member</string>
|
||||
<string name="GroupJoinBottomSheetDialogFragment_join">Join</string>
|
||||
<string name="GroupJoinBottomSheetDialogFragment_request_to_join">Request to join</string>
|
||||
<string name="GroupJoinBottomSheetDialogFragment_unable_to_join_group_please_try_again_later">Unable to join group. Please try again later</string>
|
||||
<string name="GroupJoinBottomSheetDialogFragment_encountered_a_network_error">Encountered a network error.</string>
|
||||
<string name="GroupJoinBottomSheetDialogFragment_this_group_link_is_not_active">This group link is not active</string>
|
||||
|
||||
<string name="GroupJoinBottomSheetDialogFragment_unable_to_get_group_information_please_try_again_later">Unable to get group information, please try again later</string>
|
||||
<string name="GroupJoinBottomSheetDialogFragment_direct_join">Do you want to join this group and share your name and photo with its members?</string>
|
||||
<string name="GroupJoinBottomSheetDialogFragment_admin_approval_needed">An admin of this group must approve your request before you can join this group. When you request to join, your name and photo will be shared with its members.</string>
|
||||
@@ -963,6 +968,7 @@
|
||||
<!-- GV2 group link approvals -->
|
||||
<string name="MessageRecord_s_approved_your_request_to_join_the_group">%1$s approved your request to join the group.</string>
|
||||
<string name="MessageRecord_s_approved_a_request_to_join_the_group_from_s">%1$s approved a request to join the group from %2$s.</string>
|
||||
<string name="MessageRecord_you_approved_a_request_to_join_the_group_from_s">You approved a request to join the group from %1$s.</string>
|
||||
<string name="MessageRecord_your_request_to_join_the_group_has_been_approved">Your request to join the group has been approved.</string>
|
||||
<string name="MessageRecord_a_request_to_join_the_group_from_s_has_been_approved">A request to join the group from %1$s has been approved.</string>
|
||||
|
||||
@@ -970,6 +976,8 @@
|
||||
<string name="MessageRecord_your_request_to_join_the_group_has_been_denied_by_an_admin">Your request to join the group has been denied by an admin.</string>
|
||||
<string name="MessageRecord_s_denied_a_request_to_join_the_group_from_s">%1$s denied a request to join the group from %2$s.</string>
|
||||
<string name="MessageRecord_a_request_to_join_the_group_from_s_has_been_denied">A request to join the group from %1$s has been denied.</string>
|
||||
<string name="MessageRecord_you_canceled_your_request_to_join_the_group">You canceled your request to join the group.</string>
|
||||
<string name="MessageRecord_s_canceled_their_request_to_join_the_group">%1$s canceled their request to join the group.</string>
|
||||
|
||||
<!-- End of GV2 specific update messages -->
|
||||
|
||||
|
@@ -1035,6 +1035,15 @@ public final class GroupsV2UpdateMessageProducerTest {
|
||||
assertThat(describeChange(change), is(singletonList("Alice approved a request to join the group from Bob.")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void you_approved_another_join_request() {
|
||||
DecryptedGroupChange change = changeBy(you)
|
||||
.approveRequest(alice)
|
||||
.build();
|
||||
|
||||
assertThat(describeChange(change), is(singletonList("You approved a request to join the group from Alice.")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unknown_approved_your_join_request() {
|
||||
DecryptedGroupChange change = changeByUnknown()
|
||||
@@ -1071,6 +1080,24 @@ public final class GroupsV2UpdateMessageProducerTest {
|
||||
assertThat(describeChange(change), is(singletonList("Your request to join the group has been denied by an admin.")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void you_cancelled_your_join_request() {
|
||||
DecryptedGroupChange change = changeBy(you)
|
||||
.denyRequest(you)
|
||||
.build();
|
||||
|
||||
assertThat(describeChange(change), is(singletonList("You canceled your request to join the group.")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void member_cancelled_their_join_request() {
|
||||
DecryptedGroupChange change = changeBy(alice)
|
||||
.denyRequest(alice)
|
||||
.build();
|
||||
|
||||
assertThat(describeChange(change), is(singletonList("Alice canceled their request to join the group.")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unknown_denied_your_join_request() {
|
||||
DecryptedGroupChange change = changeByUnknown()
|
||||
|
@@ -197,6 +197,10 @@ public final class GroupStateMapperTest {
|
||||
public void known_group_three_states_to_update_update_latest_handle_gap_with_changes() {
|
||||
DecryptedGroup currentState = state(0);
|
||||
ServerGroupLogEntry log1 = serverLogEntry(1);
|
||||
DecryptedGroup state3a = DecryptedGroup.newBuilder()
|
||||
.setRevision(3)
|
||||
.setTitle("Group Revision " + 3)
|
||||
.build();
|
||||
DecryptedGroup state3 = DecryptedGroup.newBuilder()
|
||||
.setRevision(3)
|
||||
.setTitle("Group Revision " + 3)
|
||||
@@ -213,11 +217,11 @@ 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"))
|
||||
.build()),
|
||||
.setRevision(3)
|
||||
.setNewAvatar(DecryptedString.newBuilder().setValue("Lost Avatar Update"))
|
||||
.build()),
|
||||
asLocal(log4))));
|
||||
|
||||
assertNewState(new GlobalGroupState(log4.getGroup(), emptyList()), advanceGroupStateResult.getNewGlobalGroupState());
|
||||
@@ -259,11 +263,16 @@ public final class GroupStateMapperTest {
|
||||
DecryptedMember newMember = DecryptedMember.newBuilder()
|
||||
.setUuid(UuidUtil.toByteString(UUID.randomUUID()))
|
||||
.build();
|
||||
ServerGroupLogEntry log8 = new ServerGroupLogEntry(DecryptedGroup.newBuilder()
|
||||
.setRevision(8)
|
||||
.addMembers(newMember)
|
||||
.setTitle("Group Revision " + 8)
|
||||
.build(),
|
||||
DecryptedGroup state7b = DecryptedGroup.newBuilder()
|
||||
.setRevision(8)
|
||||
.setTitle("Group Revision " + 8)
|
||||
.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()
|
||||
.setRevision(8)
|
||||
.addNewMembers(newMember)
|
||||
.build())),
|
||||
new LocalGroupLogEntry(state7b, log8.getChange()),
|
||||
new LocalGroupLogEntry(state8, DecryptedGroupChange.newBuilder()
|
||||
.setRevision(8)
|
||||
.addNewMembers(newMember)
|
||||
.build()),
|
||||
asLocal(log9))));
|
||||
assertNewState(new GlobalGroupState(log9.getGroup(), emptyList()), advanceGroupStateResult.getNewGlobalGroupState());
|
||||
assertEquals(log9.getGroup(), advanceGroupStateResult.getNewGlobalGroupState().getLocalState());
|
||||
@@ -298,18 +307,136 @@ public final class GroupStateMapperTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void local_on_same_revision_but_incorrect_repair_necessary() {
|
||||
public void no_repair_change_is_posted_if_the_local_state_is_a_placeholder() {
|
||||
DecryptedGroup currentState = DecryptedGroup.newBuilder()
|
||||
.setRevision(6)
|
||||
.setRevision(GroupStateMapper.PLACEHOLDER_REVISION)
|
||||
.setTitle("Incorrect group title, Revision " + 6)
|
||||
.build();
|
||||
ServerGroupLogEntry log6 = serverLogEntryWholeStateOnly(6);
|
||||
ServerGroupLogEntry log6 = serverLogEntry(6);
|
||||
|
||||
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, singletonList(log6)), LATEST);
|
||||
|
||||
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(singletonList(localLogEntryNoEditor(6))));
|
||||
assertNewState(new GlobalGroupState(state(6), emptyList()), advanceGroupStateResult.getNewGlobalGroupState());
|
||||
assertEquals(state(6), advanceGroupStateResult.getNewGlobalGroupState().getLocalState());
|
||||
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(singletonList(asLocal(log6))));
|
||||
assertTrue(advanceGroupStateResult.getNewGlobalGroupState().getServerHistory().isEmpty());
|
||||
assertEquals(log6.getGroup(), advanceGroupStateResult.getNewGlobalGroupState().getLocalState());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void clears_changes_duplicated_in_the_placeholder() {
|
||||
UUID newMemberUuid = UUID.randomUUID();
|
||||
DecryptedMember newMember = DecryptedMember.newBuilder()
|
||||
.setUuid(UuidUtil.toByteString(newMemberUuid))
|
||||
.build();
|
||||
DecryptedMember existingMember = DecryptedMember.newBuilder()
|
||||
.setUuid(UuidUtil.toByteString(UUID.randomUUID()))
|
||||
.build();
|
||||
DecryptedGroup currentState = DecryptedGroup.newBuilder()
|
||||
.setRevision(GroupStateMapper.PLACEHOLDER_REVISION)
|
||||
.setTitle("Group Revision " + 8)
|
||||
.addMembers(newMember)
|
||||
.build();
|
||||
ServerGroupLogEntry log8 = new ServerGroupLogEntry(DecryptedGroup.newBuilder()
|
||||
.setRevision(8)
|
||||
.addMembers(newMember)
|
||||
.addMembers(existingMember)
|
||||
.setTitle("Group Revision " + 8)
|
||||
.build(),
|
||||
DecryptedGroupChange.newBuilder()
|
||||
.setRevision(8)
|
||||
.setEditor(UuidUtil.toByteString(newMemberUuid))
|
||||
.addNewMembers(newMember)
|
||||
.build());
|
||||
|
||||
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, singletonList(log8)), LATEST);
|
||||
|
||||
assertNotNull(log8.getGroup());
|
||||
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(emptyList()));
|
||||
assertNewState(new GlobalGroupState(log8.getGroup(), emptyList()), advanceGroupStateResult.getNewGlobalGroupState());
|
||||
assertEquals(log8.getGroup(), advanceGroupStateResult.getNewGlobalGroupState().getLocalState());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void clears_changes_duplicated_in_a_non_placeholder() {
|
||||
UUID editorUuid = UUID.randomUUID();
|
||||
UUID newMemberUuid = UUID.randomUUID();
|
||||
DecryptedMember newMember = DecryptedMember.newBuilder()
|
||||
.setUuid(UuidUtil.toByteString(newMemberUuid))
|
||||
.build();
|
||||
DecryptedMember existingMember = DecryptedMember.newBuilder()
|
||||
.setUuid(UuidUtil.toByteString(UUID.randomUUID()))
|
||||
.build();
|
||||
DecryptedGroup currentState = DecryptedGroup.newBuilder()
|
||||
.setRevision(8)
|
||||
.setTitle("Group Revision " + 8)
|
||||
.addMembers(existingMember)
|
||||
.build();
|
||||
ServerGroupLogEntry log8 = new ServerGroupLogEntry(DecryptedGroup.newBuilder()
|
||||
.setRevision(8)
|
||||
.addMembers(existingMember)
|
||||
.addMembers(newMember)
|
||||
.setTitle("Group Revision " + 8)
|
||||
.build(),
|
||||
DecryptedGroupChange.newBuilder()
|
||||
.setRevision(8)
|
||||
.setEditor(UuidUtil.toByteString(editorUuid))
|
||||
.addNewMembers(existingMember)
|
||||
.addNewMembers(newMember)
|
||||
.build());
|
||||
|
||||
DecryptedGroupChange expectedChange = DecryptedGroupChange.newBuilder()
|
||||
.setRevision(8)
|
||||
.setEditor(UuidUtil.toByteString(editorUuid))
|
||||
.addNewMembers(newMember)
|
||||
.build();
|
||||
|
||||
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, singletonList(log8)), LATEST);
|
||||
|
||||
assertNotNull(log8.getGroup());
|
||||
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(singletonList(new LocalGroupLogEntry(log8.getGroup(), expectedChange))));
|
||||
assertNewState(new GlobalGroupState(log8.getGroup(), emptyList()), advanceGroupStateResult.getNewGlobalGroupState());
|
||||
assertEquals(log8.getGroup(), advanceGroupStateResult.getNewGlobalGroupState().getLocalState());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void notices_changes_in_avatar_and_title_but_not_members_in_placeholder() {
|
||||
UUID newMemberUuid = UUID.randomUUID();
|
||||
DecryptedMember newMember = DecryptedMember.newBuilder()
|
||||
.setUuid(UuidUtil.toByteString(newMemberUuid))
|
||||
.build();
|
||||
DecryptedMember existingMember = DecryptedMember.newBuilder()
|
||||
.setUuid(UuidUtil.toByteString(UUID.randomUUID()))
|
||||
.build();
|
||||
DecryptedGroup currentState = DecryptedGroup.newBuilder()
|
||||
.setRevision(GroupStateMapper.PLACEHOLDER_REVISION)
|
||||
.setTitle("Incorrect group title")
|
||||
.setAvatar("Incorrect group avatar")
|
||||
.addMembers(newMember)
|
||||
.build();
|
||||
ServerGroupLogEntry log8 = new ServerGroupLogEntry(DecryptedGroup.newBuilder()
|
||||
.setRevision(8)
|
||||
.addMembers(newMember)
|
||||
.addMembers(existingMember)
|
||||
.setTitle("Group Revision " + 8)
|
||||
.setAvatar("Group Avatar " + 8)
|
||||
.build(),
|
||||
DecryptedGroupChange.newBuilder()
|
||||
.setRevision(8)
|
||||
.setEditor(UuidUtil.toByteString(newMemberUuid))
|
||||
.addNewMembers(newMember)
|
||||
.build());
|
||||
|
||||
DecryptedGroupChange expectedChange = DecryptedGroupChange.newBuilder()
|
||||
.setRevision(8)
|
||||
.setNewTitle(DecryptedString.newBuilder().setValue("Group Revision " + 8))
|
||||
.setNewAvatar(DecryptedString.newBuilder().setValue("Group Avatar " + 8))
|
||||
.build();
|
||||
|
||||
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, singletonList(log8)), LATEST);
|
||||
|
||||
assertNotNull(log8.getGroup());
|
||||
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(singletonList(new LocalGroupLogEntry(log8.getGroup(), expectedChange))));
|
||||
assertNewState(new GlobalGroupState(log8.getGroup(), emptyList()), advanceGroupStateResult.getNewGlobalGroupState());
|
||||
assertEquals(log8.getGroup(), advanceGroupStateResult.getNewGlobalGroupState().getLocalState());
|
||||
}
|
||||
|
||||
private static void assertNewState(GlobalGroupState expected, GlobalGroupState actual) {
|
||||
|
@@ -0,0 +1,133 @@
|
||||
package org.thoughtcrime.securesms.groups.v2.processing;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static java.util.Arrays.asList;
|
||||
import static java.util.Collections.emptyList;
|
||||
import static java.util.Collections.singletonList;
|
||||
import static org.hamcrest.CoreMatchers.is;
|
||||
import static org.junit.Assert.assertThat;
|
||||
|
||||
public final class StateChainTest {
|
||||
|
||||
private static final int BAD_DELTA = 256;
|
||||
|
||||
private final StateChain<Character, Integer> stateChain = new StateChain<>(
|
||||
(c, d) -> {
|
||||
if (d == BAD_DELTA) return null;
|
||||
return (char) (c + d);
|
||||
},
|
||||
(a, b) -> a - b,
|
||||
(a, b)->a==b);
|
||||
|
||||
@Test
|
||||
public void push_one_state_pair() {
|
||||
stateChain.push('A', 0);
|
||||
|
||||
assertThat(stateChain.getList(), is(singletonList(pair('A', 0))));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void push_two_state_pairs() {
|
||||
stateChain.push('A', 0);
|
||||
stateChain.push('B', 1);
|
||||
|
||||
assertThat(stateChain.getList(), is(asList(pair('A', 0),
|
||||
pair('B', 1))));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void push_two_state_pairs_null_first_delta() {
|
||||
stateChain.push('A', null);
|
||||
stateChain.push('B', 1);
|
||||
|
||||
assertThat(stateChain.getList(), is(asList(pair('A', null),
|
||||
pair('B', 1))));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void push_two_state_pairs_with_missing_delta() {
|
||||
stateChain.push('A', 0);
|
||||
stateChain.push('B', null);
|
||||
|
||||
assertThat(stateChain.getList(), is(asList(pair('A', 0),
|
||||
pair('B', 1))));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void push_two_state_pairs_with_missing_state() {
|
||||
stateChain.push('A', 0);
|
||||
stateChain.push(null, 1);
|
||||
|
||||
assertThat(stateChain.getList(), is(asList(pair('A', 0),
|
||||
pair('B', 1))));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void push_one_state_pairs_with_missing_state_and_delta() {
|
||||
stateChain.push(null, null);
|
||||
|
||||
assertThat(stateChain.getList(), is(emptyList()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void push_two_state_pairs_with_missing_state_and_delta() {
|
||||
stateChain.push('A', 0);
|
||||
stateChain.push(null, null);
|
||||
|
||||
assertThat(stateChain.getList(), is(singletonList(pair('A', 0))));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void push_two_state_pairs_that_do_not_match() {
|
||||
stateChain.push('D', 0);
|
||||
stateChain.push('E', 2);
|
||||
|
||||
assertThat(stateChain.getList(), is(asList(pair('D', 0),
|
||||
pair('F', 2),
|
||||
pair('E', -1))));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void push_one_state_pair_null_delta() {
|
||||
stateChain.push('A', null);
|
||||
|
||||
assertThat(stateChain.getList(), is(singletonList(pair('A', null))));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void push_two_state_pairs_with_no_diff() {
|
||||
stateChain.push('Z', null);
|
||||
stateChain.push('Z', 0);
|
||||
|
||||
assertThat(stateChain.getList(), is(singletonList(pair('Z', null))));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void push_one_state_pair_null_state() {
|
||||
stateChain.push(null, 1);
|
||||
|
||||
assertThat(stateChain.getList(), is(emptyList()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bad_delta_results_in_reconstruction() {
|
||||
stateChain.push('C', 0);
|
||||
stateChain.push('F', BAD_DELTA);
|
||||
|
||||
assertThat(stateChain.getList(), is(asList(pair('C', 0),
|
||||
pair('F', 3))));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bad_delta_and_no_state_results_in_change_ignore() {
|
||||
stateChain.push('C', 0);
|
||||
stateChain.push(null, BAD_DELTA);
|
||||
|
||||
assertThat(stateChain.getList(), is(singletonList(pair('C', 0))));
|
||||
}
|
||||
|
||||
private static StateChain.Pair<Character, Integer> pair(char c, Integer i) {
|
||||
return new StateChain.Pair<>(c, i);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user