diff --git a/app/build.gradle b/app/build.gradle index 5f4e54adc..6001fe5e0 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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) { diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/database/RecipientDatabaseTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/database/RecipientDatabaseTest.kt index 9cc895a22..c88b45cbb 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/database/RecipientDatabaseTest.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/database/RecipientDatabaseTest.kt @@ -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) diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/database/RecipientDatabaseTest_merges.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/database/RecipientDatabaseTest_merges.kt index 197e2f78e..3266f35cd 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/database/RecipientDatabaseTest_merges.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/database/RecipientDatabaseTest_merges.kt @@ -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 = Optional.empty()): IncomingTextMessage { return IncomingTextMessage(sender, 1, time, time, time, body, groupId, 0, true, null) } diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/database/SmsDatabaseTest_collapseJoinRequestEventsIfPossible.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/database/SmsDatabaseTest_collapseJoinRequestEventsIfPossible.kt new file mode 100644 index 000000000..197a52d52 --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/database/SmsDatabaseTest_collapseJoinRequestEventsIfPossible.kt @@ -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) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java index 16426a6ed..3eb07c6fc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java @@ -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, " + diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java index ea1ff4744..dfc6eddc9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -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 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 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 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 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); + } } 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 de93c5e2d..42bdc4f62 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 @@ -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 updates) { + Set 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 updates) { + Set 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) { 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 455ad0954..4fd026e75 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 @@ -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)}. */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/sms/GroupV2UpdateMessageUtil.java b/app/src/main/java/org/thoughtcrime/securesms/sms/GroupV2UpdateMessageUtil.java index 63565fe21..f45b77123 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sms/GroupV2UpdateMessageUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sms/GroupV2UpdateMessageUtil.java @@ -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 getChangeEditor(MessageGroupContext groupContext) { + if (isGroupV2(groupContext) && isUpdate(groupContext)) { + return Optional.ofNullable(groupContext.requireGroupV2Properties().getChange().getEditor()); + } + return Optional.empty(); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/sms/IncomingGroupUpdateMessage.java b/app/src/main/java/org/thoughtcrime/securesms/sms/IncomingGroupUpdateMessage.java index 9e00692e7..eb0557fac 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sms/IncomingGroupUpdateMessage.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sms/IncomingGroupUpdateMessage.java @@ -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 getChangeEditor() { + return GroupV2UpdateMessageUtil.getChangeEditor(groupContext); + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1c48331a2..a2d2a4469 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1245,6 +1245,11 @@ You sent a request to join the group. %1$s requested to join via the group link. + + + %1$s requested and cancelled their request to join via the group link. + %1$s requested and cancelled %2$d requests to join via the group link. + %1$s approved your request to join the group. diff --git a/app/src/spinner/java/org/thoughtcrime/securesms/SpinnerApplicationContext.kt b/app/src/spinner/java/org/thoughtcrime/securesms/SpinnerApplicationContext.kt index 0c243221f..44e865878 100644 --- a/app/src/spinner/java/org/thoughtcrime/securesms/SpinnerApplicationContext.kt +++ b/app/src/spinner/java/org/thoughtcrime/securesms/SpinnerApplicationContext.kt @@ -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), diff --git a/app/src/spinner/java/org/thoughtcrime/securesms/database/GV2Transformer.kt b/app/src/spinner/java/org/thoughtcrime/securesms/database/GV2Transformer.kt new file mode 100644 index 000000000..26a3a2c28 --- /dev/null +++ b/app/src/spinner/java/org/thoughtcrime/securesms/database/GV2Transformer.kt @@ -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("
") { 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", "
") +} diff --git a/app/src/spinner/java/org/thoughtcrime/securesms/database/GV2UpdateTransformer.kt b/app/src/spinner/java/org/thoughtcrime/securesms/database/GV2UpdateTransformer.kt new file mode 100644 index 000000000..825f62a19 --- /dev/null +++ b/app/src/spinner/java/org/thoughtcrime/securesms/database/GV2UpdateTransformer.kt @@ -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 + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/GroupTestUtil.kt b/app/src/test/java/org/thoughtcrime/securesms/database/GroupTestUtil.kt index aeab324ef..175e3bc7c 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/database/GroupTestUtil.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/database/GroupTestUtil.kt @@ -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() -} diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/model/MessageRecordTest_createNewContextWithAppendedDeleteJoinRequest.kt b/app/src/test/java/org/thoughtcrime/securesms/database/model/MessageRecordTest_createNewContextWithAppendedDeleteJoinRequest.kt new file mode 100644 index 000000000..d46d2e726 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/database/model/MessageRecordTest_createNewContextWithAppendedDeleteJoinRequest.kt @@ -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 { + 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 { + 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 { + 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 + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/groups/GroupManagerV2Test_edit.kt b/app/src/test/java/org/thoughtcrime/securesms/groups/GroupManagerV2Test_edit.kt index 542200398..762152b0a 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/groups/GroupManagerV2Test_edit.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/groups/GroupManagerV2Test_edit.kt @@ -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 diff --git a/app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessorTest.kt b/app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessorTest.kt index fcf52ad71..d19e1090a 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessorTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessorTest.kt @@ -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 diff --git a/app/src/test/java/org/thoughtcrime/securesms/sms/GroupV2UpdateMessageUtilTest.java b/app/src/test/java/org/thoughtcrime/securesms/sms/GroupV2UpdateMessageUtilTest.java index 8edb7e693..9062dc5cb 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/sms/GroupV2UpdateMessageUtilTest.java +++ b/app/src/test/java/org/thoughtcrime/securesms/sms/GroupV2UpdateMessageUtilTest.java @@ -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); diff --git a/app/src/testShared/org/thoughtcrime/securesms/database/model/databaseprotos/DecryptedGroupHelper.kt b/app/src/testShared/org/thoughtcrime/securesms/database/model/databaseprotos/DecryptedGroupHelper.kt new file mode 100644 index 000000000..6bda586b4 --- /dev/null +++ b/app/src/testShared/org/thoughtcrime/securesms/database/model/databaseprotos/DecryptedGroupHelper.kt @@ -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() +} diff --git a/dependencies.gradle b/dependencies.gradle index e29fee87d..8cb9f3861 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -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') diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 92076f81d..7229dae91 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -3207,6 +3207,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + diff --git a/spinner/lib/src/main/assets/browse.hbs b/spinner/lib/src/main/assets/browse.hbs index ecf4296ad..637e13718 100644 --- a/spinner/lib/src/main/assets/browse.hbs +++ b/spinner/lib/src/main/assets/browse.hbs @@ -47,7 +47,7 @@ {{#each queryResult.rows}} {{#each this}} - {{{this}}} +
{{{this}}}
{{/each}} {{/each}} diff --git a/spinner/lib/src/main/assets/query.hbs b/spinner/lib/src/main/assets/query.hbs index a852840dc..1d9d104bd 100644 --- a/spinner/lib/src/main/assets/query.hbs +++ b/spinner/lib/src/main/assets/query.hbs @@ -31,7 +31,7 @@ {{#each queryResult.rows}} {{#each this}} - {{{this}}} +
{{{this}}}
{{/each}} {{/each}} diff --git a/spinner/lib/src/main/java/org/signal/spinner/DefaultColumnTransformer.kt b/spinner/lib/src/main/java/org/signal/spinner/DefaultColumnTransformer.kt index d1060a284..f2983eaab 100644 --- a/spinner/lib/src/main/java/org/signal/spinner/DefaultColumnTransformer.kt +++ b/spinner/lib/src/main/java/org/signal/spinner/DefaultColumnTransformer.kt @@ -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 }