diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java index 6f4eb704e..729fdbf87 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -1119,19 +1119,19 @@ public class ThreadDatabase extends Database { Recipient resolved = Recipient.resolved(threadRecipientId); if (resolved.isPushGroup()) { if (resolved.isPushV2Group()) { - DecryptedGroup decryptedGroup = DatabaseFactory.getGroupDatabase(context).requireGroup(resolved.requireGroupId().requireV2()).requireV2GroupProperties().getDecryptedGroup(); - Optional inviter = DecryptedGroupUtil.findInviter(decryptedGroup.getPendingMembersList(), Recipient.self().getUuid().get()); - - if (inviter.isPresent()) { - RecipientId recipientId = RecipientId.from(inviter.get(), null); - return Extra.forGroupV2invite(recipientId); - } else if (decryptedGroup.getRevision() == 0) { - Optional foundingMember = DecryptedGroupUtil.firstMember(decryptedGroup.getMembersList()); - - if (foundingMember.isPresent()) { - return Extra.forGroupMessageRequest(RecipientId.from(UuidUtil.fromByteString(foundingMember.get().getUuid()), null)); + MessageRecord.InviteAddState inviteAddState = record.getGv2AddInviteState(); + if (inviteAddState != null) { + RecipientId from = RecipientId.from(inviteAddState.getAddedOrInvitedBy(), null); + if (inviteAddState.isInvited()) { + Log.i(TAG, "GV2 invite message request from " + from); + return Extra.forGroupV2invite(from); + } else { + Log.i(TAG, "GV2 message request from " + from); + return Extra.forGroupMessageRequest(from); } } + Log.w(TAG, "Falling back to unknown message request state for GV2 message"); + return Extra.forMessageRequest(); } else { RecipientId recipientId = DatabaseFactory.getMmsSmsDatabase(context).getGroupAddedBy(record.getThreadId()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducer.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducer.java index 9f6fd22f3..3f0de33ff 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducer.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducer.java @@ -53,23 +53,24 @@ final class GroupsV2UpdateMessageProducer { /** * Describes a group that is new to you, use this when there is no available change record. *

- * Invitation and groups you create are the most common cases where no change is available. + * Invitation and revision 0 groups are the most common use cases for this. + *

+ * When invited, it's possible there's no change available. + *

+ * When the revision of the group is 0, the change is very noisy and only the editor is useful. */ - UpdateDescription describeNewGroup(@NonNull DecryptedGroup group) { + UpdateDescription describeNewGroup(@NonNull DecryptedGroup group, @NonNull DecryptedGroupChange decryptedGroupChange) { Optional selfPending = DecryptedGroupUtil.findPendingByUuid(group.getPendingMembersList(), selfUuid); if (selfPending.isPresent()) { return updateDescription(selfPending.get().getAddedByUuid(), inviteBy -> context.getString(R.string.MessageRecord_s_invited_you_to_the_group, inviteBy)); } - if (group.getRevision() == 0) { - Optional foundingMember = DecryptedGroupUtil.firstMember(group.getMembersList()); - if (foundingMember.isPresent()) { - ByteString foundingMemberUuid = foundingMember.get().getUuid(); - if (selfUuidBytes.equals(foundingMemberUuid)) { - return updateDescription(context.getString(R.string.MessageRecord_you_created_the_group)); - } else { - return updateDescription(foundingMemberUuid, creator -> context.getString(R.string.MessageRecord_s_added_you, creator)); - } + ByteString foundingMemberUuid = decryptedGroupChange.getEditor(); + if (!foundingMemberUuid.isEmpty()) { + if (selfUuidBytes.equals(foundingMemberUuid)) { + return updateDescription(context.getString(R.string.MessageRecord_you_created_the_group)); + } else { + return updateDescription(foundingMemberUuid, creator -> context.getString(R.string.MessageRecord_s_added_you, creator)); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java index 30a510f61..19906c3fe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java @@ -25,6 +25,7 @@ import android.text.style.StyleSpan; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import org.signal.storageservice.protos.groups.local.DecryptedGroup; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.database.MmsSmsColumns; import org.thoughtcrime.securesms.database.SmsDatabase; @@ -41,6 +42,7 @@ import org.thoughtcrime.securesms.util.ExpirationUtil; import org.thoughtcrime.securesms.util.GroupUtil; import org.thoughtcrime.securesms.util.StringUtil; import org.whispersystems.libsignal.util.guava.Function; +import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil; import org.whispersystems.signalservice.api.util.UuidUtil; import java.io.IOException; @@ -177,7 +179,7 @@ public abstract class MessageRecord extends DisplayRecord { if (decryptedGroupV2Context.hasChange() && decryptedGroupV2Context.getGroupState().getRevision() != 0) { return UpdateDescription.concatWithNewLines(updateMessageProducer.describeChanges(decryptedGroupV2Context.getChange())); } else { - return updateMessageProducer.describeNewGroup(decryptedGroupV2Context.getGroupState()); + return updateMessageProducer.describeNewGroup(decryptedGroupV2Context.getGroupState(), decryptedGroupV2Context.getChange()); } } catch (IOException e) { Log.w(TAG, "GV2 Message update detail could not be read", e); @@ -185,6 +187,29 @@ public abstract class MessageRecord extends DisplayRecord { } } + public @Nullable InviteAddState getGv2AddInviteState() { + try { + byte[] decoded = Base64.decode(getBody()); + DecryptedGroupV2Context decryptedGroupV2Context = DecryptedGroupV2Context.parseFrom(decoded); + DecryptedGroup groupState = decryptedGroupV2Context.getGroupState(); + boolean invited = DecryptedGroupUtil.findPendingByUuid(groupState.getPendingMembersList(), Recipient.self().requireUuid()).isPresent(); + + if (decryptedGroupV2Context.hasChange()) { + UUID changeEditor = UuidUtil.fromByteStringOrNull(decryptedGroupV2Context.getChange().getEditor()); + + if (changeEditor != null) { + return new InviteAddState(invited, changeEditor); + } + } + + Log.w(TAG, "GV2 Message editor could not be determined"); + return null; + } catch (IOException e) { + Log.w(TAG, "GV2 Message update detail could not be read", e); + return null; + } + } + private static @NonNull UpdateDescription fromRecipient(@NonNull Recipient recipient, @NonNull Function stringFunction) { return UpdateDescription.mentioning(Collections.singletonList(recipient.getUuid().or(UuidUtil.UNKNOWN_UUID)), () -> stringFunction.apply(recipient.resolve())); } @@ -378,4 +403,23 @@ public abstract class MessageRecord extends DisplayRecord { public boolean hasSelfMention() { return false; } + + public static final class InviteAddState { + + private final boolean invited; + private final UUID addedOrInvitedBy; + + public InviteAddState(boolean invited, @NonNull UUID addedOrInvitedBy) { + this.invited = invited; + this.addedOrInvitedBy = addedOrInvitedBy; + } + + public @NonNull UUID getAddedOrInvitedBy() { + return addedOrInvitedBy; + } + + public boolean isInvited() { + return invited; + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java index d2a5a12fc..965a9f00d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java @@ -48,6 +48,7 @@ import org.thoughtcrime.securesms.sms.MessageSender; import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil; import org.whispersystems.signalservice.api.groupsv2.GroupCandidate; +import org.whispersystems.signalservice.api.groupsv2.GroupChangeReconstruct; import org.whispersystems.signalservice.api.groupsv2.GroupChangeUtil; import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException; import org.whispersystems.signalservice.api.groupsv2.GroupsV2Api; @@ -188,7 +189,11 @@ final class GroupManagerV2 { groupDatabase.onAvatarUpdated(groupId, avatar != null); DatabaseFactory.getRecipientDatabase(context).setProfileSharing(groupRecipient.getId(), true); - RecipientAndThread recipientAndThread = sendGroupUpdate(masterKey, decryptedGroup, null, null); + DecryptedGroupChange groupChange = DecryptedGroupChange.newBuilder(GroupChangeReconstruct.reconstructGroupChange(DecryptedGroup.newBuilder().build(), decryptedGroup)) + .setEditor(UuidUtil.toByteString(selfUuid)) + .build(); + + RecipientAndThread recipientAndThread = sendGroupUpdate(masterKey, decryptedGroup, groupChange, null); return new GroupManager.GroupActionResult(recipientAndThread.groupRecipient, recipientAndThread.threadId, diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java index 11690c563..3af96db4a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java @@ -7,6 +7,8 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; +import com.annimon.stream.Stream; + import org.signal.storageservice.protos.groups.local.DecryptedGroup; import org.signal.storageservice.protos.groups.local.DecryptedGroupChange; import org.signal.storageservice.protos.groups.local.DecryptedMember; @@ -209,6 +211,7 @@ public final class GroupsV2StateProcessor { } updateLocalDatabaseGroupState(inputGroupState, newLocalState); + determineProfileSharing(inputGroupState, newLocalState); insertUpdateMessages(timestamp, advanceGroupStateResult.getProcessedLogEntries()); persistLearnedProfileKeys(inputGroupState); @@ -293,30 +296,52 @@ public final class GroupsV2StateProcessor { jobManager.add(new AvatarGroupsV2DownloadJob(groupId, newLocalState.getAvatar())); } - boolean fullMemberPostUpdate = GroupProtoUtil.isMember(Recipient.self().getUuid().get(), newLocalState.getMembersList()); - boolean trustedAdder = false; + determineProfileSharing(inputGroupState, newLocalState); + } - if (newLocalState.getRevision() == 0) { - Optional foundingMember = DecryptedGroupUtil.firstMember(newLocalState.getMembersList()); + private void determineProfileSharing(@NonNull GlobalGroupState inputGroupState, + @NonNull DecryptedGroup newLocalState) + { + if (inputGroupState.getLocalState() != null) { + boolean wasAMemberAlready = DecryptedGroupUtil.findMemberByUuid(inputGroupState.getLocalState().getMembersList(), Recipient.self().getUuid().get()).isPresent(); - if (foundingMember.isPresent()) { - UUID foundingMemberUuid = UuidUtil.fromByteString(foundingMember.get().getUuid()); - Recipient foundingRecipient = Recipient.externalPush(context, foundingMemberUuid, null, false); - - if (foundingRecipient.isSystemContact() || foundingRecipient.isProfileSharing()) { - Log.i(TAG, "Group 'adder' is trusted. contact: " + foundingRecipient.isSystemContact() + ", profileSharing: " + foundingRecipient.isProfileSharing()); - trustedAdder = true; - } - } else { - Log.i(TAG, "Could not find founding member during gv2 create. Not enabling profile sharing."); + if (wasAMemberAlready) { + Log.i(TAG, "Skipping profile sharing detection as was already a full member before update"); + return; } } - if (fullMemberPostUpdate && trustedAdder) { - Log.i(TAG, "Added to a group and auto-enabling profile sharing"); - recipientDatabase.setProfileSharing(Recipient.externalGroup(context, groupId).getId(), true); + Optional selfAsMemberOptional = DecryptedGroupUtil.findMemberByUuid(newLocalState.getMembersList(), Recipient.self().getUuid().get()); + + if (selfAsMemberOptional.isPresent()) { + DecryptedMember selfAsMember = selfAsMemberOptional.get(); + int revisionJoinedAt = selfAsMember.getJoinedAtRevision(); + + Optional addedByOptional = Stream.of(inputGroupState.getServerHistory()) + .map(ServerGroupLogEntry::getChange) + .filter(c -> c != null && c.getRevision() == revisionJoinedAt) + .findFirst() + .map(c -> Optional.fromNullable(UuidUtil.fromByteStringOrNull(c.getEditor())) + .transform(a -> Recipient.externalPush(context, UuidUtil.fromByteStringOrNull(c.getEditor()), null, false))) + .orElse(Optional.absent()); + + if (addedByOptional.isPresent()) { + Recipient addedBy = addedByOptional.get(); + + Log.i(TAG, String.format("Added as a full member of %s by %s", groupId, addedBy.getId())); + + if (addedBy.isSystemContact() || addedBy.isProfileSharing()) { + Log.i(TAG, "Group 'adder' is trusted. contact: " + addedBy.isSystemContact() + ", profileSharing: " + addedBy.isProfileSharing()); + Log.i(TAG, "Added to a group and auto-enabling profile sharing"); + recipientDatabase.setProfileSharing(Recipient.externalGroup(context, groupId).getId(), true); + } else { + Log.i(TAG, "Added to a group, but not enabling profile sharing, as 'adder' is not trusted"); + } + } else { + Log.w(TAG, "Could not find founding member during gv2 create. Not enabling profile sharing."); + } } else { - Log.i(TAG, "Added to a group, but not enabling profile sharing. fullMember: " + fullMemberPostUpdate + ", trustedAdded: " + trustedAdder); + Log.i(TAG, String.format("Added to %s, but not enabling profile sharing as not a fullMember.", groupId)); } } diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducerTest.java b/app/src/test/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducerTest.java index 95ca387d3..25efd4611 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducerTest.java +++ b/app/src/test/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducerTest.java @@ -1196,12 +1196,36 @@ public final class GroupsV2UpdateMessageProducerTest { // Group state without a change record + @Test + public void you_created_a_group_change_not_found() { + DecryptedGroup group = newGroupBy(you, 0) + .build(); + + assertThat(describeNewGroup(group), is("You joined the group.")); + } + @Test public void you_created_a_group() { DecryptedGroup group = newGroupBy(you, 0) .build(); - assertThat(describeNewGroup(group), is("You created the group.")); + DecryptedGroupChange change = changeBy(you) + .addMember(alice) + .addMember(you) + .addMember(bob) + .title("New title") + .build(); + + assertThat(describeNewGroup(group, change), is("You created the group.")); + } + + @Test + public void alice_created_a_group_change_not_found() { + DecryptedGroup group = newGroupBy(alice, 0) + .member(you) + .build(); + + assertThat(describeNewGroup(group), is("You joined the group.")); } @Test @@ -1210,7 +1234,14 @@ public final class GroupsV2UpdateMessageProducerTest { .member(you) .build(); - assertThat(describeNewGroup(group), is("Alice added you to the group.")); + DecryptedGroupChange change = changeBy(alice) + .addMember(you) + .addMember(alice) + .addMember(bob) + .title("New title") + .build(); + + assertThat(describeNewGroup(group, change), is("Alice added you to the group.")); } @Test @@ -1247,8 +1278,12 @@ public final class GroupsV2UpdateMessageProducerTest { } private @NonNull String describeNewGroup(@NonNull DecryptedGroup group) { + return describeNewGroup(group, DecryptedGroupChange.getDefaultInstance()); + } + + private @NonNull String describeNewGroup(@NonNull DecryptedGroup group, @NonNull DecryptedGroupChange groupChange) { MainThreadUtil.setMainThread(false); - return producer.describeNewGroup(group).getString(); + return producer.describeNewGroup(group, groupChange).getString(); } private static GroupStateBuilder newGroupBy(UUID foundingMember, int revision) { diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil.java index 2ae80f0eb..d4228b2a1 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil.java @@ -160,16 +160,6 @@ public final class DecryptedGroupUtil { return Optional.absent(); } - public static Optional firstMember(Collection members) { - Iterator iterator = members.iterator(); - - if (iterator.hasNext()) { - return Optional.of(iterator.next()); - } else { - return Optional.absent(); - } - } - public static Optional findPendingByUuid(Collection members, UUID uuid) { ByteString uuidBytes = UuidUtil.toByteString(uuid);