Collapse multiple join request/cancels when from a single person.

fork-5.53.8
Cody Henthorne 2022-03-14 20:49:40 -04:00
rodzic 216059b659
commit 9d1f46da9f
25 zmienionych plików z 736 dodań i 41 usunięć

Wyświetl plik

@ -524,6 +524,7 @@ dependencies {
testImplementation testLibs.junit.junit
testImplementation testLibs.assertj.core
testImplementation testLibs.mockito.core
testImplementation testLibs.mockito.kotlin
testImplementation testLibs.androidx.test.core
testImplementation (testLibs.robolectric.robolectric) {

Wyświetl plik

@ -18,9 +18,9 @@ import org.thoughtcrime.securesms.keyvalue.MockKeyValuePersistentStorage
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.libsignal.util.guava.Optional
import org.whispersystems.signalservice.api.push.ACI
import org.whispersystems.signalservice.api.push.PNI
import java.util.Optional
import java.util.UUID
@RunWith(AndroidJUnit4::class)

Wyświetl plik

@ -9,7 +9,6 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@ -34,10 +33,10 @@ import org.thoughtcrime.securesms.util.CursorUtil
import org.whispersystems.libsignal.IdentityKey
import org.whispersystems.libsignal.SignalProtocolAddress
import org.whispersystems.libsignal.state.SessionRecord
import org.whispersystems.libsignal.util.guava.Optional
import org.whispersystems.signalservice.api.push.ACI
import org.whispersystems.signalservice.api.push.PNI
import org.whispersystems.signalservice.api.util.UuidUtil
import java.util.Optional
import java.util.UUID
@RunWith(AndroidJUnit4::class)
@ -76,8 +75,6 @@ class RecipientDatabaseTest_merges {
SignalStore.account().setAci(localAci)
SignalStore.account().setPni(localPni)
ensureDbEmpty()
}
/** High trust lets you merge two different users into one. You should prefer the ACI user. Not shown: merging threads, dropping e164 sessions, etc. */
@ -217,13 +214,6 @@ class RecipientDatabaseTest_merges {
private val context: Application
get() = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as Application
private fun ensureDbEmpty() {
SignalDatabase.rawDatabase.rawQuery("SELECT COUNT(*) FROM ${RecipientDatabase.TABLE_NAME}", null).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(0, cursor.getLong(0))
}
}
private fun smsMessage(sender: RecipientId, time: Long = 0, body: String = "", groupId: Optional<GroupId> = Optional.empty()): IncomingTextMessage {
return IncomingTextMessage(sender, 1, time, time, time, body, groupId, 0, true, null)
}

Wyświetl plik

@ -0,0 +1,292 @@
package org.thoughtcrime.securesms.database
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.`is`
import org.hamcrest.Matchers.notNullValue
import org.hamcrest.Matchers.nullValue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.zkgroup.groups.GroupMasterKey
import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context
import org.thoughtcrime.securesms.database.model.databaseprotos.addMember
import org.thoughtcrime.securesms.database.model.databaseprotos.addRequestingMember
import org.thoughtcrime.securesms.database.model.databaseprotos.deleteRequestingMember
import org.thoughtcrime.securesms.database.model.databaseprotos.groupChange
import org.thoughtcrime.securesms.database.model.databaseprotos.groupContext
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sms.IncomingGroupUpdateMessage
import org.thoughtcrime.securesms.sms.IncomingTextMessage
import org.thoughtcrime.securesms.util.Hex
import org.whispersystems.signalservice.api.push.ACI
import org.whispersystems.signalservice.api.push.PNI
import org.whispersystems.signalservice.api.push.ServiceId
import java.util.Optional
import java.util.UUID
@Suppress("ClassName", "TestFunctionName")
@RunWith(AndroidJUnit4::class)
class SmsDatabaseTest_collapseJoinRequestEventsIfPossible {
private lateinit var recipients: RecipientDatabase
private lateinit var sms: SmsDatabase
private val localAci = ACI.from(UUID.randomUUID())
private val localPni = PNI.from(UUID.randomUUID())
private var wallClock: Long = 1000
private lateinit var alice: RecipientId
private lateinit var bob: RecipientId
@Before
fun setUp() {
recipients = SignalDatabase.recipients
sms = SignalDatabase.sms
SignalStore.account().setAci(localAci)
SignalStore.account().setPni(localPni)
alice = recipients.getOrInsertFromServiceId(aliceServiceId)
bob = recipients.getOrInsertFromServiceId(bobServiceId)
}
/**
* Do nothing if no previous messages.
*/
@Test
fun noPreviousMessage() {
val result = sms.collapseJoinRequestEventsIfPossible(
1,
groupUpdateMessage(
sender = alice,
groupContext = groupContext(masterKey = masterKey) {
change = groupChange(editor = aliceServiceId) {
deleteRequestingMember(aliceServiceId)
}
}
)
)
assertThat("result is null when not collapsing", result.orElse(null), nullValue())
}
/**
* Do nothing if previous message is text.
*/
@Test
fun previousTextMesssage() {
val threadId = sms.insertMessageInbox(smsMessage(sender = alice, body = "What up")).get().threadId
val result = sms.collapseJoinRequestEventsIfPossible(
threadId,
groupUpdateMessage(
sender = alice,
groupContext = groupContext(masterKey = masterKey) {
change = groupChange(editor = aliceServiceId) {
deleteRequestingMember(aliceServiceId)
}
}
)
)
assertThat("result is null when not collapsing", result.orElse(null), nullValue())
}
/**
* Do nothing if previous is unrelated group change.
*/
@Test
fun previousUnrelatedGroupChange() {
val threadId = sms.insertMessageInbox(
groupUpdateMessage(
sender = alice,
groupContext = groupContext(masterKey = masterKey) {
change = groupChange(editor = aliceServiceId) {
addMember(bobServiceId)
}
}
)
).get().threadId
val result = sms.collapseJoinRequestEventsIfPossible(
threadId,
groupUpdateMessage(
sender = alice,
groupContext = groupContext(masterKey = masterKey) {
change = groupChange(editor = aliceServiceId) {
deleteRequestingMember(aliceServiceId)
}
}
)
)
assertThat("result is null when not collapsing", result.orElse(null), nullValue())
}
/**
* Do nothing if previous join request is from a different recipient.
*/
@Test
fun previousJoinRequestFromADifferentRecipient() {
val threadId = sms.insertMessageInbox(
groupUpdateMessage(
sender = bob,
groupContext = groupContext(masterKey = masterKey) {
change = groupChange(editor = bobServiceId) {
deleteRequestingMember(bobServiceId)
}
}
)
).get().threadId
val result = sms.collapseJoinRequestEventsIfPossible(
threadId,
groupUpdateMessage(
sender = alice,
groupContext = groupContext(masterKey = masterKey) {
change = groupChange(editor = aliceServiceId) {
deleteRequestingMember(aliceServiceId)
}
}
)
)
assertThat("result is null when not collapsing", result.orElse(null), nullValue())
}
/**
* Collapse if previous is join request from same.
*/
@Test
fun previousJoinRequestCollapse() {
val latestMessage: MessageDatabase.InsertResult = sms.insertMessageInbox(
groupUpdateMessage(
sender = alice,
groupContext = groupContext(masterKey = masterKey) {
change = groupChange(editor = aliceServiceId) {
addRequestingMember(aliceServiceId)
}
}
)
).get()
val result = sms.collapseJoinRequestEventsIfPossible(
latestMessage.threadId,
groupUpdateMessage(
sender = alice,
groupContext = groupContext(masterKey = masterKey) {
change = groupChange(editor = aliceServiceId) {
deleteRequestingMember(aliceServiceId)
}
}
)
)
assertThat("result is not null when collapsing", result.orElse(null), notNullValue())
assertThat("result message id should be same as latest message", result.get().messageId, `is`(latestMessage.messageId))
}
/**
* Collapse if previous is join request from same, and leave second previous alone if text.
*/
@Test
fun previousJoinThenTextCollapse() {
val secondLatestMessage = sms.insertMessageInbox(smsMessage(sender = alice, body = "What up")).get()
val latestMessage: MessageDatabase.InsertResult = sms.insertMessageInbox(
groupUpdateMessage(
sender = alice,
groupContext = groupContext(masterKey = masterKey) {
change = groupChange(editor = aliceServiceId) {
addRequestingMember(aliceServiceId)
}
}
)
).get()
assert(secondLatestMessage.threadId == latestMessage.threadId)
val result = sms.collapseJoinRequestEventsIfPossible(
latestMessage.threadId,
groupUpdateMessage(
sender = alice,
groupContext = groupContext(masterKey = masterKey) {
change = groupChange(editor = aliceServiceId) {
deleteRequestingMember(aliceServiceId)
}
}
)
)
assertThat("result is not null when collapsing", result.orElse(null), notNullValue())
assertThat("result message id should be same as latest message", result.get().messageId, `is`(latestMessage.messageId))
}
/**
* Collapse "twice" is previous is a join request and second previous is already collapsed join/delete from the same recipient.
*/
@Test
fun previousCollapseAndJoinRequestDoubleCollapse() {
val secondLatestMessage: MessageDatabase.InsertResult = sms.insertMessageInbox(
groupUpdateMessage(
sender = alice,
groupContext = groupContext(masterKey = masterKey) {
change = groupChange(editor = aliceServiceId) {
addRequestingMember(aliceServiceId)
deleteRequestingMember(aliceServiceId)
}
}
)
).get()
val latestMessage: MessageDatabase.InsertResult = sms.insertMessageInbox(
groupUpdateMessage(
sender = alice,
groupContext = groupContext(masterKey = masterKey) {
change = groupChange(editor = aliceServiceId) {
addRequestingMember(aliceServiceId)
}
}
)
).get()
assert(secondLatestMessage.threadId == latestMessage.threadId)
val result = sms.collapseJoinRequestEventsIfPossible(
latestMessage.threadId,
groupUpdateMessage(
sender = alice,
groupContext = groupContext(masterKey = masterKey) {
change = groupChange(editor = aliceServiceId) {
deleteRequestingMember(aliceServiceId)
}
}
)
)
assertThat("result is not null when collapsing", result.orElse(null), notNullValue())
assertThat("result message id should be same as second latest message", result.get().messageId, `is`(secondLatestMessage.messageId))
assertThat("latest message should be deleted", sms.getMessageRecordOrNull(latestMessage.messageId), nullValue())
}
private fun smsMessage(sender: RecipientId, body: String? = ""): IncomingTextMessage {
wallClock++
return IncomingTextMessage(sender, 1, wallClock, wallClock, wallClock, body, Optional.of(groupId), 0, true, null)
}
private fun groupUpdateMessage(sender: RecipientId, groupContext: DecryptedGroupV2Context): IncomingGroupUpdateMessage {
return IncomingGroupUpdateMessage(smsMessage(sender, null), groupContext)
}
companion object {
private val aliceServiceId: ServiceId = ACI.from(UUID.fromString("3436efbe-5a76-47fa-a98a-7e72c948a82e"))
private val bobServiceId: ServiceId = ACI.from(UUID.fromString("8de7f691-0b60-4a68-9cd9-ed2f8453f9ed"))
private val masterKey = GroupMasterKey(Hex.fromStringCondensed("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"))
private val groupId = GroupId.v2(masterKey)
}
}

Wyświetl plik

@ -91,7 +91,7 @@ public class GroupDatabase extends Database {
/** Increments with every change to the group */
private static final String V2_REVISION = "revision";
/** Serialized {@link DecryptedGroup} protobuf */
private static final String V2_DECRYPTED_GROUP = "decrypted_group";
public static final String V2_DECRYPTED_GROUP = "decrypted_group";
public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY, " +
GROUP_ID + " TEXT, " +

Wyświetl plik

@ -28,6 +28,7 @@ import androidx.annotation.VisibleForTesting;
import com.annimon.stream.Stream;
import com.google.android.mms.pdu_alt.NotificationInd;
import com.google.protobuf.ByteString;
import net.zetetic.database.sqlcipher.SQLiteStatement;
@ -1093,6 +1094,8 @@ public class SmsDatabase extends MessageDatabase {
@Override
public Optional<InsertResult> insertMessageInbox(IncomingTextMessage message, long type) {
boolean tryToCollapseJoinRequestEvents = false;
if (message.isJoined()) {
type = (type & (Types.TOTAL_MASK - Types.BASE_TYPE_MASK)) | Types.JOINED_TYPE;
} else if (message.isPreKeyBundle()) {
@ -1108,6 +1111,8 @@ public class SmsDatabase extends MessageDatabase {
type |= Types.GROUP_V2_BIT | Types.GROUP_UPDATE_BIT;
if (incomingGroupUpdateMessage.isJustAGroupLeave()) {
type |= Types.GROUP_LEAVE_BIT;
} else if (incomingGroupUpdateMessage.isCancelJoinRequest()) {
tryToCollapseJoinRequestEvents = true;
}
} else if (incomingGroupUpdateMessage.isUpdate()) {
type |= Types.GROUP_UPDATE_BIT;
@ -1152,6 +1157,13 @@ public class SmsDatabase extends MessageDatabase {
if (groupRecipient == null) threadId = SignalDatabase.threads().getOrCreateThreadIdFor(recipient);
else threadId = SignalDatabase.threads().getOrCreateThreadIdFor(groupRecipient);
if (tryToCollapseJoinRequestEvents) {
final Optional<InsertResult> result = collapseJoinRequestEventsIfPossible(threadId, (IncomingGroupUpdateMessage) message);
if (result.isPresent()) {
return result;
}
}
ContentValues values = new ContentValues();
values.put(RECIPIENT_ID, message.getSender().serialize());
values.put(ADDRESS_DEVICE_ID, message.getSenderDeviceId());
@ -1809,4 +1821,44 @@ public class SmsDatabase extends MessageDatabase {
}
}
@VisibleForTesting
Optional<InsertResult> collapseJoinRequestEventsIfPossible(long threadId, IncomingGroupUpdateMessage message) {
InsertResult result = null;
SQLiteDatabase db = getWritableDatabase();
db.beginTransaction();
try {
try (MmsSmsDatabase.Reader reader = MmsSmsDatabase.readerFor(SignalDatabase.mmsSms().getConversation(threadId, 0, 2))) {
MessageRecord latestMessage = reader.getNext();
if (latestMessage != null && latestMessage.isGroupV2()) {
Optional<ByteString> changeEditor = message.getChangeEditor();
if (changeEditor.isPresent() && latestMessage.isGroupV2JoinRequest(changeEditor.get())) {
String encodedBody;
long id;
MessageRecord secondLatestMessage = reader.getNext();
if (secondLatestMessage != null && secondLatestMessage.isGroupV2JoinRequest(changeEditor.get())) {
id = secondLatestMessage.getId();
encodedBody = MessageRecord.createNewContextWithAppendedDeleteJoinRequest(secondLatestMessage, message.getChangeRevision(), changeEditor.get());
deleteMessage(latestMessage.getId());
} else {
id = latestMessage.getId();
encodedBody = MessageRecord.createNewContextWithAppendedDeleteJoinRequest(latestMessage, message.getChangeRevision(), changeEditor.get());
}
ContentValues values = new ContentValues(1);
values.put(BODY, encodedBody);
getWritableDatabase().update(TABLE_NAME, values, ID_WHERE, SqlUtil.buildArgs(id));
result = new InsertResult(id, threadId);
}
}
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
return Optional.ofNullable(result);
}
}

Wyświetl plik

@ -30,10 +30,13 @@ import org.whispersystems.signalservice.api.util.UuidUtil;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
final class GroupsV2UpdateMessageProducer {
@ -639,13 +642,22 @@ final class GroupsV2UpdateMessageProducer {
}
private void describeRequestingMembers(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
Set<ByteString> deleteRequestingUuids = new HashSet<>(change.getDeleteRequestingMembersList());
for (DecryptedRequestingMember member : change.getNewRequestingMembersList()) {
boolean requestingMemberIsYou = member.getUuid().equals(selfUuidBytes);
if (requestingMemberIsYou) {
updates.add(updateDescription(context.getString(R.string.MessageRecord_you_sent_a_request_to_join_the_group), R.drawable.ic_update_group_16));
} else {
updates.add(updateDescription(member.getUuid(), requesting -> context.getString(R.string.MessageRecord_s_requested_to_join_via_the_group_link, requesting), R.drawable.ic_update_group_16));
if (deleteRequestingUuids.contains(member.getUuid())) {
updates.add(updateDescription(member.getUuid(), requesting -> context.getResources().getQuantityString(R.plurals.MessageRecord_s_requested_and_cancelled_their_request_to_join_via_the_group_link,
change.getDeleteRequestingMembersCount(),
requesting,
change.getDeleteRequestingMembersCount()), R.drawable.ic_update_group_16));
} else {
updates.add(updateDescription(member.getUuid(), requesting -> context.getString(R.string.MessageRecord_s_requested_to_join_via_the_group_link, requesting), R.drawable.ic_update_group_16));
}
}
}
}
@ -681,9 +693,15 @@ final class GroupsV2UpdateMessageProducer {
}
private void describeRequestingMembersDeletes(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
Set<ByteString> newRequestingUuids = change.getNewRequestingMembersList().stream().map(r -> r.getUuid()).collect(Collectors.toSet());
boolean editorIsYou = change.getEditor().equals(selfUuidBytes);
for (ByteString requestingMember : change.getDeleteRequestingMembersList()) {
if (newRequestingUuids.contains(requestingMember)) {
continue;
}
boolean requestingMemberIsYou = requestingMember.equals(selfUuidBytes);
if (requestingMemberIsYou) {

Wyświetl plik

@ -26,10 +26,12 @@ import androidx.annotation.ColorInt;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.annotation.WorkerThread;
import androidx.core.content.ContextCompat;
import com.annimon.stream.Stream;
import com.google.protobuf.ByteString;
import org.signal.core.util.logging.Log;
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
@ -71,6 +73,8 @@ import java.util.Set;
import java.util.UUID;
import java.util.function.Function;
import kotlin.collections.CollectionsKt;
/**
* The base class for message record models that are displayed in
* conversations, as opposed to models that are displayed in a thread list.
@ -234,7 +238,8 @@ public abstract class MessageRecord extends DisplayRecord {
return selfCreatedGroup(change);
}
private @Nullable DecryptedGroupV2Context getDecryptedGroupV2Context() {
@VisibleForTesting
@Nullable DecryptedGroupV2Context getDecryptedGroupV2Context() {
if (!isGroupUpdate() || !isGroupV2()) {
return null;
}
@ -409,6 +414,31 @@ public abstract class MessageRecord extends DisplayRecord {
return "";
}
public boolean isGroupV2JoinRequest(ByteString uuid) {
DecryptedGroupV2Context decryptedGroupV2Context = getDecryptedGroupV2Context();
if (decryptedGroupV2Context != null && decryptedGroupV2Context.hasChange()) {
DecryptedGroupChange change = decryptedGroupV2Context.getChange();
return change.getEditor().equals(uuid) && change.getNewRequestingMembersList().stream().anyMatch(r -> r.getUuid().equals(uuid));
}
return false;
}
public static @NonNull String createNewContextWithAppendedDeleteJoinRequest(@NonNull MessageRecord messageRecord, int revision, @NonNull ByteString id) {
DecryptedGroupV2Context decryptedGroupV2Context = messageRecord.getDecryptedGroupV2Context();
if (decryptedGroupV2Context != null && decryptedGroupV2Context.hasChange()) {
DecryptedGroupChange change = decryptedGroupV2Context.getChange();
return Base64.encodeBytes(decryptedGroupV2Context.toBuilder()
.setChange(change.toBuilder()
.setRevision(revision)
.addDeleteRequestingMembers(id))
.build().toByteArray());
}
throw new AssertionError("Attempting to modify a message with no change");
}
/**
* Describes a UUID by it's corresponding recipient's {@link Recipient#getDisplayName(Context)}.
*/

Wyświetl plik

@ -2,10 +2,14 @@ package org.thoughtcrime.securesms.sms;
import androidx.annotation.NonNull;
import com.google.protobuf.ByteString;
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
import org.thoughtcrime.securesms.mms.MessageGroupContext;
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil;
import java.util.Optional;
/**
* Helper util for inspecting GV2 {@link MessageGroupContext} for various message processing.
*/
@ -42,4 +46,29 @@ public final class GroupV2UpdateMessageUtil {
.build();
return DecryptedGroupUtil.changeIsEmpty(withoutDeletedMembers);
}
public static boolean isJoinRequestCancel(@NonNull MessageGroupContext groupContext) {
if (isGroupV2(groupContext) && isUpdate(groupContext)) {
DecryptedGroupChange decryptedGroupChange = groupContext.requireGroupV2Properties()
.getChange();
return decryptedGroupChange.getDeleteRequestingMembersCount() > 0;
}
return false;
}
public static int getChangeRevision(@NonNull MessageGroupContext groupContext) {
if (isGroupV2(groupContext) && isUpdate(groupContext)) {
return groupContext.requireGroupV2Properties().getChange().getRevision();
}
return -1;
}
public static Optional<ByteString> getChangeEditor(MessageGroupContext groupContext) {
if (isGroupV2(groupContext) && isUpdate(groupContext)) {
return Optional.ofNullable(groupContext.requireGroupV2Properties().getChange().getEditor());
}
return Optional.empty();
}
}

Wyświetl plik

@ -1,8 +1,12 @@
package org.thoughtcrime.securesms.sms;
import com.google.protobuf.ByteString;
import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context;
import org.thoughtcrime.securesms.mms.MessageGroupContext;
import java.util.Optional;
import static org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContext;
public final class IncomingGroupUpdateMessage extends IncomingTextMessage {
@ -43,4 +47,16 @@ public final class IncomingGroupUpdateMessage extends IncomingTextMessage {
public boolean isJustAGroupLeave() {
return GroupV2UpdateMessageUtil.isJustAGroupLeave(groupContext);
}
public boolean isCancelJoinRequest() {
return GroupV2UpdateMessageUtil.isJoinRequestCancel(groupContext);
}
public int getChangeRevision() {
return GroupV2UpdateMessageUtil.getChangeRevision(groupContext);
}
public Optional<ByteString> getChangeEditor() {
return GroupV2UpdateMessageUtil.getChangeEditor(groupContext);
}
}

Wyświetl plik

@ -1245,6 +1245,11 @@
<!-- GV2 group link requests -->
<string name="MessageRecord_you_sent_a_request_to_join_the_group">You sent a request to join the group.</string>
<string name="MessageRecord_s_requested_to_join_via_the_group_link">%1$s requested to join via the group link.</string>
<!-- Update message shown when someone requests to join via group link and cancels the request back to back -->
<plurals name="MessageRecord_s_requested_and_cancelled_their_request_to_join_via_the_group_link">
<item quantity="one">%1$s requested and cancelled their request to join via the group link.</item>
<item quantity="other">%1$s requested and cancelled %2$d requests to join via the group link.</item>
</plurals>
<!-- 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>

Wyświetl plik

@ -6,6 +6,8 @@ import leakcanary.LeakCanary
import org.signal.spinner.Spinner
import org.signal.spinner.Spinner.DatabaseConfig
import org.thoughtcrime.securesms.database.DatabaseMonitor
import org.thoughtcrime.securesms.database.GV2Transformer
import org.thoughtcrime.securesms.database.GV2UpdateTransformer
import org.thoughtcrime.securesms.database.JobDatabase
import org.thoughtcrime.securesms.database.KeyValueDatabase
import org.thoughtcrime.securesms.database.LocalMetricsDatabase
@ -37,7 +39,7 @@ class SpinnerApplicationContext : ApplicationContext() {
linkedMapOf(
"signal" to DatabaseConfig(
db = SignalDatabase.rawDatabase,
columnTransformers = listOf(MessageBitmaskColumnTransformer)
columnTransformers = listOf(MessageBitmaskColumnTransformer, GV2Transformer, GV2UpdateTransformer)
),
"jobmanager" to DatabaseConfig(db = JobDatabase.getInstance(this).sqlCipherDatabase),
"keyvalue" to DatabaseConfig(db = KeyValueDatabase.getInstance(this).sqlCipherDatabase),

Wyświetl plik

@ -0,0 +1,38 @@
package org.thoughtcrime.securesms.database
import android.database.Cursor
import org.signal.spinner.ColumnTransformer
import org.signal.storageservice.protos.groups.local.DecryptedGroup
object GV2Transformer : ColumnTransformer {
override fun matches(tableName: String?, columnName: String): Boolean {
return columnName == GroupDatabase.V2_DECRYPTED_GROUP || columnName == GroupDatabase.MEMBERS
}
override fun transform(tableName: String?, columnName: String, cursor: Cursor): String {
return if (columnName == GroupDatabase.V2_DECRYPTED_GROUP) {
val groupBytes = cursor.requireBlob(GroupDatabase.V2_DECRYPTED_GROUP)
val group = DecryptedGroup.parseFrom(groupBytes)
group.formatAsHtml()
} else {
val members = cursor.requireString(GroupDatabase.MEMBERS)
members?.split(',')?.chunked(20)?.joinToString("<br>") { it.joinToString(",") } ?: ""
}
}
}
private fun DecryptedGroup.formatAsHtml(): String {
return """
Revision: $revision
Title: $title
Avatar: ${(avatar?.length ?: 0) != 0}
Timer: ${disappearingMessagesTimer.duration}
Description: "$description"
Announcement: $isAnnouncementGroup
Access: attributes(${accessControl.attributes}) members(${accessControl.members}) link(${accessControl.addFromInviteLink})
Members: $membersCount
Pending: $pendingMembersCount
Requesting: $requestingMembersCount
Banned: $bannedMembersCount
""".trimIndent().replace("\n", "<br>")
}

Wyświetl plik

@ -0,0 +1,40 @@
package org.thoughtcrime.securesms.database
import android.database.Cursor
import org.signal.spinner.ColumnTransformer
import org.signal.spinner.DefaultColumnTransformer
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.UpdateDescription
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.util.CursorUtil
object GV2UpdateTransformer : ColumnTransformer {
override fun matches(tableName: String?, columnName: String): Boolean {
return columnName == MmsSmsColumns.BODY && (tableName == null || (tableName == SmsDatabase.TABLE_NAME || tableName == MmsDatabase.TABLE_NAME))
}
override fun transform(tableName: String?, columnName: String, cursor: Cursor): String {
val type: Long = cursor.getMessageType()
if (type == -1L) {
return DefaultColumnTransformer.transform(tableName, columnName, cursor)
}
val body: String? = CursorUtil.requireString(cursor, MmsSmsColumns.BODY)
return if (MmsSmsColumns.Types.isGroupV2(type) && MmsSmsColumns.Types.isGroupUpdate(type) && body != null) {
val gv2ChangeDescription: UpdateDescription = MessageRecord.getGv2ChangeDescription(ApplicationDependencies.getApplication(), body)
gv2ChangeDescription.string
} else {
body ?: ""
}
}
}
private fun Cursor.getMessageType(): Long {
return when {
getColumnIndex(SmsDatabase.TYPE) != -1 -> requireLong(SmsDatabase.TYPE)
getColumnIndex(MmsDatabase.MESSAGE_BOX) != -1 -> requireLong(MmsDatabase.MESSAGE_BOX)
else -> -1
}
}

Wyświetl plik

@ -21,7 +21,6 @@ import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations
import org.whispersystems.signalservice.api.push.DistributionId
import org.whispersystems.signalservice.api.push.ServiceId
import java.util.Optional
import java.util.UUID
fun DecryptedGroupChange.Builder.setNewDescription(description: String) {
newDescription = DecryptedString.newBuilder().setValue(description).build()
@ -224,21 +223,3 @@ fun decryptedGroup(
return builder.build()
}
fun member(serviceId: UUID, role: Member.Role = Member.Role.DEFAULT, joinedAt: Int = 0): DecryptedMember {
return member(ServiceId.from(serviceId), role, joinedAt)
}
fun member(serviceId: ServiceId, role: Member.Role = Member.Role.DEFAULT, joinedAt: Int = 0): DecryptedMember {
return DecryptedMember.newBuilder()
.setRole(role)
.setUuid(serviceId.toByteString())
.setJoinedAtRevision(joinedAt)
.build()
}
fun requestingMember(serviceId: ServiceId): DecryptedRequestingMember {
return DecryptedRequestingMember.newBuilder()
.setUuid(serviceId.toByteString())
.build()
}

Wyświetl plik

@ -0,0 +1,83 @@
package org.thoughtcrime.securesms.database.model
import com.google.protobuf.ByteString
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.`is`
import org.junit.Test
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context
import org.thoughtcrime.securesms.groups.v2.ChangeBuilder
import org.thoughtcrime.securesms.util.Base64
import org.whispersystems.signalservice.api.util.UuidUtil
import org.whispersystems.signalservice.internal.push.SignalServiceProtos
import java.util.Random
import java.util.UUID
@Suppress("ClassName")
class MessageRecordTest_createNewContextWithAppendedDeleteJoinRequest {
/**
* Given a non-gv2 message, when I append, then I expect an assertion error
*/
@Test(expected = AssertionError::class)
fun throwOnNonGv2() {
val messageRecord = mock<MessageRecord> {
on { decryptedGroupV2Context } doReturn null
}
MessageRecord.createNewContextWithAppendedDeleteJoinRequest(messageRecord, 0, ByteString.EMPTY)
}
/**
* Given a gv2 empty change, when I append, then I expect an assertion error.
*/
@Test(expected = AssertionError::class)
fun throwOnEmptyGv2Change() {
val groupContext = DecryptedGroupV2Context.getDefaultInstance()
val messageRecord = mock<MessageRecord> {
on { decryptedGroupV2Context } doReturn groupContext
}
MessageRecord.createNewContextWithAppendedDeleteJoinRequest(messageRecord, 0, ByteString.EMPTY)
}
/**
* Given a gv2 requesting member change, when I append, then I expect new group context including the change with a new delete.
*/
@Test
fun appendDeleteToExistingContext() {
val alice = UUID.randomUUID()
val aliceByteString = UuidUtil.toByteString(alice)
val change = ChangeBuilder.changeBy(alice)
.requestJoin(alice)
.build()
.toBuilder()
.setRevision(9)
.build()
val context = DecryptedGroupV2Context.newBuilder()
.setContext(SignalServiceProtos.GroupContextV2.newBuilder().setMasterKey(ByteString.copyFrom(randomBytes())))
.setChange(change)
.build()
val messageRecord = mock<MessageRecord> {
on { decryptedGroupV2Context } doReturn context
}
val newEncodedBody = MessageRecord.createNewContextWithAppendedDeleteJoinRequest(messageRecord, 10, aliceByteString)
val newContext = DecryptedGroupV2Context.parseFrom(Base64.decode(newEncodedBody))
assertThat("revision updated to 10", newContext.change.revision, `is`(10))
assertThat("change should retain join request", newContext.change.newRequestingMembersList[0].uuid, `is`(aliceByteString))
assertThat("change should add delete request", newContext.change.deleteRequestingMembersList[0], `is`(aliceByteString))
}
private fun randomBytes(): ByteArray {
val bytes = ByteArray(32)
Random().nextBytes(bytes)
return bytes
}
}

Wyświetl plik

@ -27,7 +27,7 @@ import org.thoughtcrime.securesms.SignalStoreRule
import org.thoughtcrime.securesms.TestZkGroupServer
import org.thoughtcrime.securesms.database.GroupDatabase
import org.thoughtcrime.securesms.database.GroupStateTestData
import org.thoughtcrime.securesms.database.member
import org.thoughtcrime.securesms.database.model.databaseprotos.member
import org.thoughtcrime.securesms.groups.v2.GroupCandidateHelper
import org.thoughtcrime.securesms.groups.v2.processing.GroupsV2StateProcessor
import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger

Wyświetl plik

@ -28,8 +28,8 @@ import org.thoughtcrime.securesms.SignalStoreRule
import org.thoughtcrime.securesms.database.GroupDatabase
import org.thoughtcrime.securesms.database.GroupStateTestData
import org.thoughtcrime.securesms.database.RecipientDatabase
import org.thoughtcrime.securesms.database.member
import org.thoughtcrime.securesms.database.requestingMember
import org.thoughtcrime.securesms.database.model.databaseprotos.member
import org.thoughtcrime.securesms.database.model.databaseprotos.requestingMember
import org.thoughtcrime.securesms.database.setNewDescription
import org.thoughtcrime.securesms.database.setNewTitle
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies

Wyświetl plik

@ -91,6 +91,54 @@ public class GroupV2UpdateMessageUtilTest {
assertFalse(isJustAGroupLeave);
}
@Test
public void isJoinRequestCancel_whenChangeRemovesRequestingMembers_shouldReturnTrue() {
// GIVEN
UUID alice = UUID.randomUUID();
DecryptedGroupChange change = ChangeBuilder.changeBy(alice)
.denyRequest(alice)
.build();
DecryptedGroupV2Context context = DecryptedGroupV2Context.newBuilder()
.setContext(SignalServiceProtos.GroupContextV2.newBuilder()
.setMasterKey(ByteString.copyFrom(randomBytes())))
.setChange(change)
.build();
MessageGroupContext messageGroupContext = new MessageGroupContext(context);
// WHEN
boolean isJoinRequestCancel = GroupV2UpdateMessageUtil.isJoinRequestCancel(messageGroupContext);
// THEN
assertTrue(isJoinRequestCancel);
}
@Test
public void isJoinRequestCancel_whenChangeContainsNoRemoveRequestingMembers_shouldReturnFalse() {
// GIVEN
UUID alice = UUID.randomUUID();
UUID bob = UUID.randomUUID();
DecryptedGroupChange change = ChangeBuilder.changeBy(alice)
.deleteMember(alice)
.addMember(bob)
.build();
DecryptedGroupV2Context context = DecryptedGroupV2Context.newBuilder()
.setContext(SignalServiceProtos.GroupContextV2.newBuilder()
.setMasterKey(ByteString.copyFrom(randomBytes())))
.setChange(change)
.build();
MessageGroupContext messageGroupContext = new MessageGroupContext(context);
// WHEN
boolean isJoinRequestCancel = GroupV2UpdateMessageUtil.isJoinRequestCancel(messageGroupContext);
// THEN
assertFalse(isJoinRequestCancel);
}
private @NonNull byte[] randomBytes() {
byte[] bytes = new byte[32];
new Random().nextBytes(bytes);

Wyświetl plik

@ -0,0 +1,64 @@
package org.thoughtcrime.securesms.database.model.databaseprotos
import com.google.protobuf.ByteString
import org.signal.storageservice.protos.groups.Member
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange
import org.signal.storageservice.protos.groups.local.DecryptedMember
import org.signal.storageservice.protos.groups.local.DecryptedRequestingMember
import org.signal.zkgroup.groups.GroupMasterKey
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.api.util.UuidUtil
import org.whispersystems.signalservice.internal.push.SignalServiceProtos
import java.util.UUID
fun groupContext(masterKey: GroupMasterKey, init: DecryptedGroupV2Context.Builder.() -> Unit): DecryptedGroupV2Context {
val builder = DecryptedGroupV2Context.newBuilder()
builder.context = encryptedGroupContext(masterKey)
builder.init()
return builder.build()
}
fun groupChange(editor: ServiceId, init: DecryptedGroupChange.Builder.() -> Unit): DecryptedGroupChange {
val builder = DecryptedGroupChange.newBuilder()
builder.editor = editor.toByteString()
builder.init()
return builder.build()
}
fun encryptedGroupContext(masterKey: GroupMasterKey): SignalServiceProtos.GroupContextV2 {
return SignalServiceProtos.GroupContextV2.newBuilder().setMasterKey(ByteString.copyFrom(masterKey.serialize())).build()
}
fun DecryptedGroupChange.Builder.addRequestingMember(serviceId: ServiceId) {
addNewRequestingMembers(requestingMember(serviceId))
}
fun DecryptedGroupChange.Builder.deleteRequestingMember(serviceId: ServiceId) {
addDeleteRequestingMembers(serviceId.toByteString())
}
fun DecryptedGroupChange.Builder.addMember(serviceId: ServiceId) {
addNewMembers(member(serviceId))
}
fun ServiceId.toByteString(): ByteString {
return UuidUtil.toByteString(uuid())
}
fun member(serviceId: UUID, role: Member.Role = Member.Role.DEFAULT, joinedAt: Int = 0): DecryptedMember {
return member(ServiceId.from(serviceId), role, joinedAt)
}
fun member(serviceId: ServiceId, role: Member.Role = Member.Role.DEFAULT, joinedAt: Int = 0): DecryptedMember {
return DecryptedMember.newBuilder()
.setRole(role)
.setUuid(serviceId.toByteString())
.setJoinedAtRevision(joinedAt)
.build()
}
fun requestingMember(serviceId: ServiceId): DecryptedRequestingMember {
return DecryptedRequestingMember.newBuilder()
.setUuid(serviceId.toByteString())
.build()
}

Wyświetl plik

@ -131,6 +131,7 @@ dependencyResolutionManagement {
alias('androidx-test-ext-junit').to('androidx.test.ext:junit:1.1.1')
alias('espresso-core').to('androidx.test.espresso:espresso-core:3.2.0')
alias('mockito-core').to('org.mockito:mockito-inline:4.4.0')
alias('mockito-kotlin').to('org.mockito.kotlin:mockito-kotlin:4.0.0')
alias('robolectric-robolectric').to('org.robolectric', 'robolectric').versionRef('robolectric')
alias('robolectric-shadows-multidex').to('org.robolectric', 'shadows-multidex').versionRef('robolectric')
alias('bouncycastle-bcprov-jdk15on').to('org.bouncycastle:bcprov-jdk15on:1.70')

Wyświetl plik

@ -3207,6 +3207,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="ee52e1c299a632184fba274a9370993e09140429f5e516e6c5570fd6574b297f" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.mockito.kotlin" name="mockito-kotlin" version="4.0.0">
<artifact name="mockito-kotlin-4.0.0.jar">
<sha256 value="046eabba9c38816f75114163ac5074630f335dcdeeac52f228ce71c732c3d75f" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.mozilla" name="rhino" version="1.7.7">
<artifact name="rhino-1.7.7.jar">
<sha256 value="73b8d6bbbd1a6a3a87ea0eea301996deac83f8d40b404518a10afd4d320b5b31" origin="Generated by Gradle"/>

Wyświetl plik

@ -47,7 +47,7 @@
{{#each queryResult.rows}}
<tr>
{{#each this}}
<td>{{{this}}}</td>
<td><pre>{{{this}}}</pre></td>
{{/each}}
</tr>
{{/each}}

Wyświetl plik

@ -31,7 +31,7 @@
{{#each queryResult.rows}}
<tr>
{{#each this}}
<td>{{{this}}}</td>
<td><pre>{{{this}}}</pre></td>
{{/each}}
</tr>
{{/each}}

Wyświetl plik

@ -3,7 +3,7 @@ package org.signal.spinner
import android.database.Cursor
import android.util.Base64
internal object DefaultColumnTransformer : ColumnTransformer {
object DefaultColumnTransformer : ColumnTransformer {
override fun matches(tableName: String?, columnName: String): Boolean {
return true
}