diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsRepository.kt index 1aae163668..15e77a9daf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsRepository.kt @@ -11,9 +11,9 @@ import org.signal.core.util.logging.Log import org.signal.storageservice.protos.groups.local.DecryptedGroup import org.signal.storageservice.protos.groups.local.DecryptedPendingMember import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery -import org.thoughtcrime.securesms.database.GroupTable import org.thoughtcrime.securesms.database.MediaTable import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.GroupRecord import org.thoughtcrime.securesms.database.model.IdentityRecord import org.thoughtcrime.securesms.database.model.StoryViewState import org.thoughtcrime.securesms.dependencies.ApplicationDependencies @@ -70,7 +70,7 @@ class ConversationSettingsRepository( fun isInternalRecipientDetailsEnabled(): Boolean = SignalStore.internalValues().recipientDetails() fun hasGroups(consumer: (Boolean) -> Unit) { - SignalExecutors.BOUNDED.execute { consumer(SignalDatabase.groups.activeGroupCount > 0) } + SignalExecutors.BOUNDED.execute { consumer(SignalDatabase.groups.getActiveGroupCount() > 0) } } fun getIdentity(recipientId: RecipientId, consumer: (IdentityRecord?) -> Unit) { @@ -91,7 +91,7 @@ class ConversationSettingsRepository( .getPushGroupsContainingMember(recipientId) .asSequence() .filter { it.members.contains(Recipient.self().id) } - .map(GroupTable.GroupRecord::getRecipientId) + .map(GroupRecord::recipientId) .map(Recipient::resolved) .sortedBy { gr -> gr.getDisplayName(context) } .toList() @@ -129,7 +129,7 @@ class ConversationSettingsRepository( fun getGroupCapacity(groupId: GroupId, consumer: (GroupCapacityResult) -> Unit) { SignalExecutors.BOUNDED.execute { - val groupRecord: GroupTable.GroupRecord = SignalDatabase.groups.getGroup(groupId).get() + val groupRecord: GroupRecord = SignalDatabase.groups.getGroup(groupId).get() consumer( if (groupRecord.isV2Group) { val decryptedGroup: DecryptedGroup = groupRecord.requireV2GroupProperties().decryptedGroup diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorLoader.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorLoader.java index 6c797217b3..409eac08bb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorLoader.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorLoader.java @@ -29,6 +29,7 @@ import org.thoughtcrime.securesms.database.GroupTable; import org.thoughtcrime.securesms.database.RecipientTable; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.ThreadTable; +import org.thoughtcrime.securesms.database.model.GroupRecord; import org.thoughtcrime.securesms.database.model.ThreadRecord; import org.thoughtcrime.securesms.phonenumbers.NumberUtil; import org.thoughtcrime.securesms.recipients.RecipientId; @@ -221,11 +222,11 @@ public class ContactsCursorLoader extends AbstractContactsCursorLoader { } private Cursor getGroupsCursor() { - MatrixCursor groupContacts = ContactsCursorRows.createMatrixCursor(); - Map groups = new LinkedHashMap<>(); + MatrixCursor groupContacts = ContactsCursorRows.createMatrixCursor(); + Map groups = new LinkedHashMap<>(); try (GroupTable.Reader reader = SignalDatabase.groups().queryGroupsByTitle(getFilter(), flagSet(mode, DisplayMode.FLAG_INACTIVE_GROUPS), hideGroupsV1(mode), !smsEnabled(mode))) { - GroupTable.GroupRecord groupRecord; + GroupRecord groupRecord; while ((groupRecord = reader.getNext()) != null) { groups.put(groupRecord.getRecipientId(), groupRecord); } @@ -240,14 +241,14 @@ public class ContactsCursorLoader extends AbstractContactsCursorLoader { } try (GroupTable.Reader reader = SignalDatabase.groups().queryGroupsByMembership(filteredContacts, flagSet(mode, DisplayMode.FLAG_INACTIVE_GROUPS), hideGroupsV1(mode), !smsEnabled(mode))) { - GroupTable.GroupRecord groupRecord; + GroupRecord groupRecord; while ((groupRecord = reader.getNext()) != null) { groups.put(groupRecord.getRecipientId(), groupRecord); } } } - for (GroupTable.GroupRecord groupRecord : groups.values()) { + for (GroupRecord groupRecord : groups.values()) { groupContacts.addRow(ContactsCursorRows.forGroup(groupRecord)); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorRows.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorRows.java index d9a2ea1330..14803b3a45 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorRows.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorRows.java @@ -8,7 +8,7 @@ import android.provider.ContactsContract; import androidx.annotation.NonNull; import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.database.GroupTable; +import org.thoughtcrime.securesms.database.model.GroupRecord; import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter; import org.thoughtcrime.securesms.recipients.Recipient; import org.whispersystems.signalservice.api.util.OptionalUtil; @@ -74,7 +74,7 @@ public final class ContactsCursorRows { /** * Create a row for a contacts cursor based off the given group record. */ - public static @NonNull Object[] forGroup(@NonNull GroupTable.GroupRecord groupRecord) { + public static @NonNull Object[] forGroup(@NonNull GroupRecord groupRecord) { return new Object[]{groupRecord.getRecipientId().serialize(), groupRecord.getTitle(), groupRecord.getId(), diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/GroupRecordContactPhoto.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/GroupRecordContactPhoto.java index 1e1798a275..e6f4d4e0d2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/GroupRecordContactPhoto.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/GroupRecordContactPhoto.java @@ -10,6 +10,7 @@ import androidx.annotation.Nullable; import org.signal.core.util.Conversions; import org.thoughtcrime.securesms.database.GroupTable; import org.thoughtcrime.securesms.database.SignalDatabase; +import org.thoughtcrime.securesms.database.model.GroupRecord; import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.profiles.AvatarHelper; @@ -30,8 +31,8 @@ public final class GroupRecordContactPhoto implements ContactPhoto { @Override public InputStream openInputStream(Context context) throws IOException { - GroupTable groupDatabase = SignalDatabase.groups(); - Optional groupRecord = groupDatabase.getGroup(groupId); + GroupTable groupDatabase = SignalDatabase.groups(); + Optional groupRecord = groupDatabase.getGroup(groupId); if (!groupRecord.isPresent() || !AvatarHelper.hasAvatar(context, groupRecord.get().getRecipientId())) { throw new IOException("No avatar for group: " + groupId); diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt index 76380ab40f..043c43f520 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt @@ -6,8 +6,8 @@ import org.thoughtcrime.securesms.contacts.paged.collections.ContactSearchCollec import org.thoughtcrime.securesms.contacts.paged.collections.ContactSearchIterator import org.thoughtcrime.securesms.contacts.paged.collections.CursorSearchIterator import org.thoughtcrime.securesms.contacts.paged.collections.StoriesSearchCollection -import org.thoughtcrime.securesms.database.GroupTable.GroupRecord import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode +import org.thoughtcrime.securesms.database.model.GroupRecord import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.keyvalue.StorySend import org.thoughtcrime.securesms.recipients.Recipient diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSourceRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSourceRepository.kt index 871cf2c9ee..e14f51deaa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSourceRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSourceRepository.kt @@ -8,10 +8,10 @@ import org.thoughtcrime.securesms.contacts.ContactRepository import org.thoughtcrime.securesms.contacts.paged.collections.ContactSearchIterator import org.thoughtcrime.securesms.database.DistributionListTables import org.thoughtcrime.securesms.database.GroupTable -import org.thoughtcrime.securesms.database.GroupTable.GroupRecord import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.ThreadTable import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode +import org.thoughtcrime.securesms.database.model.GroupRecord import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.keyvalue.StorySend import org.thoughtcrime.securesms.recipients.Recipient @@ -104,7 +104,7 @@ open class ContactSearchPagedDataSourceRepository( } open fun getGroupStories(): Set { - return SignalDatabase.groups.groupsToDisplayAsStories.map { + return SignalDatabase.groups.getGroupsToDisplayAsStories().map { val recipient = Recipient.resolved(SignalDatabase.recipients.getOrInsertFromGroupId(it)) ContactSearchData.Story(recipient, recipient.participantIds.size, DistributionListPrivacyMode.ALL) }.toSet() diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationGroupViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationGroupViewModel.java index 488c4ca9e4..0c6182e98a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationGroupViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationGroupViewModel.java @@ -16,7 +16,7 @@ import com.annimon.stream.Stream; import org.signal.core.util.concurrent.SignalExecutors; import org.thoughtcrime.securesms.database.GroupTable; -import org.thoughtcrime.securesms.database.GroupTable.GroupRecord; +import org.thoughtcrime.securesms.database.model.GroupRecord; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.groups.GroupChangeBusyException; diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java index 8299dae7b0..583d43267a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java @@ -328,7 +328,8 @@ import io.reactivex.rxjava3.core.Flowable; import io.reactivex.rxjava3.disposables.Disposable; import static android.content.res.Configuration.ORIENTATION_LANDSCAPE; -import static org.thoughtcrime.securesms.database.GroupTable.GroupRecord; + +import org.thoughtcrime.securesms.database.model.GroupRecord; /** * Fragment for displaying a message thread, as well as diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationRepository.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationRepository.java index 78dcbebfc5..736e8cbacd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationRepository.java @@ -10,11 +10,11 @@ import org.signal.core.util.concurrent.SignalExecutors; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery; import org.thoughtcrime.securesms.database.DatabaseObserver; -import org.thoughtcrime.securesms.database.GroupTable; import org.thoughtcrime.securesms.database.MessageTable; import org.thoughtcrime.securesms.database.RecipientTable; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.ThreadTable; +import org.thoughtcrime.securesms.database.model.GroupRecord; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.jobs.MultiDeviceViewedUpdateJob; import org.thoughtcrime.securesms.keyvalue.SignalStore; @@ -84,7 +84,7 @@ class ConversationRepository { boolean isGroup = false; boolean recipientIsKnownOrHasGroupsInCommon = false; if (conversationRecipient.isGroup()) { - Optional group = SignalDatabase.groups().getGroup(conversationRecipient.getId()); + Optional group = SignalDatabase.groups().getGroup(conversationRecipient.getId()); if (group.isPresent()) { List recipients = Recipient.resolvedList(group.get().getMembers()); for (Recipient recipient : recipients) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ShowAdminsBottomSheetDialog.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ShowAdminsBottomSheetDialog.java index a5c9460d7c..1d98c48f36 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ShowAdminsBottomSheetDialog.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ShowAdminsBottomSheetDialog.java @@ -15,8 +15,8 @@ import androidx.fragment.app.FragmentManager; import com.google.android.material.bottomsheet.BottomSheetDialogFragment; import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.database.GroupTable; import org.thoughtcrime.securesms.database.SignalDatabase; +import org.thoughtcrime.securesms.database.model.GroupRecord; import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.groups.ParcelableGroupId; import org.thoughtcrime.securesms.groups.ui.GroupMemberListView; @@ -94,7 +94,7 @@ public final class ShowAdminsBottomSheetDialog extends BottomSheetDialogFragment private static @NonNull List getAdmins(@NonNull Context context, @NonNull GroupId groupId) { return SignalDatabase.groups() .getGroup(groupId) - .map(GroupTable.GroupRecord::getAdmins) + .map(GroupRecord::getAdmins) .orElse(Collections.emptyList()); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.java b/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.java deleted file mode 100644 index 26eed71f5b..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.java +++ /dev/null @@ -1,1712 +0,0 @@ -package org.thoughtcrime.securesms.database; - -import android.annotation.SuppressLint; -import android.content.ContentValues; -import android.content.Context; -import android.database.Cursor; -import android.text.TextUtils; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.WorkerThread; - -import com.annimon.stream.Stream; -import com.google.protobuf.InvalidProtocolBufferException; - -import org.signal.core.util.CursorUtil; -import org.signal.core.util.SQLiteDatabaseExtensionsKt; -import org.signal.core.util.SetUtil; -import org.signal.core.util.SqlUtil; -import org.signal.core.util.logging.Log; -import org.signal.libsignal.zkgroup.InvalidInputException; -import org.signal.libsignal.zkgroup.groups.GroupMasterKey; -import org.signal.storageservice.protos.groups.AccessControl; -import org.signal.storageservice.protos.groups.Member; -import org.signal.storageservice.protos.groups.local.DecryptedGroup; -import org.signal.storageservice.protos.groups.local.DecryptedGroupChange; -import org.signal.storageservice.protos.groups.local.EnabledState; -import org.thoughtcrime.securesms.contacts.paged.ContactSearchSortOrder; -import org.thoughtcrime.securesms.contacts.paged.collections.ContactSearchIterator; -import org.thoughtcrime.securesms.crypto.SenderKeyUtil; -import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; -import org.thoughtcrime.securesms.groups.BadGroupIdException; -import org.thoughtcrime.securesms.groups.GroupAccessControl; -import org.thoughtcrime.securesms.groups.GroupId; -import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange; -import org.thoughtcrime.securesms.groups.v2.processing.GroupsV2StateProcessor; -import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob; -import org.thoughtcrime.securesms.keyvalue.SignalStore; -import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.recipients.RecipientId; -import org.thoughtcrime.securesms.util.Util; -import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil; -import org.whispersystems.signalservice.api.groupsv2.GroupChangeReconstruct; -import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer; -import org.whispersystems.signalservice.api.push.ACI; -import org.whispersystems.signalservice.api.push.DistributionId; -import org.whispersystems.signalservice.api.push.ServiceId; -import org.whispersystems.signalservice.api.util.UuidUtil; - -import java.io.Closeable; -import java.security.SecureRandom; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.LinkedHashSet; -import java.util.LinkedList; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; -import java.util.stream.Collectors; - -public class GroupTable extends DatabaseTable implements RecipientIdDatabaseReference { - - private static final String TAG = Log.tag(GroupTable.class); - - static final String TABLE_NAME = "groups"; - private static final String ID = "_id"; - static final String GROUP_ID = "group_id"; - public static final String RECIPIENT_ID = "recipient_id"; - private static final String TITLE = "title"; - static final String MEMBERS = "members"; - private static final String AVATAR_ID = "avatar_id"; - private static final String AVATAR_KEY = "avatar_key"; - private static final String AVATAR_CONTENT_TYPE = "avatar_content_type"; - private static final String AVATAR_RELAY = "avatar_relay"; - private static final String AVATAR_DIGEST = "avatar_digest"; - private static final String TIMESTAMP = "timestamp"; - static final String ACTIVE = "active"; - static final String MMS = "mms"; - private static final String EXPECTED_V2_ID = "expected_v2_id"; - private static final String UNMIGRATED_V1_MEMBERS = "former_v1_members"; - private static final String DISTRIBUTION_ID = "distribution_id"; - private static final String SHOW_AS_STORY_STATE = "display_as_story"; - private static final String LAST_FORCE_UPDATE_TIMESTAMP = "last_force_update_timestamp"; - - /** Was temporarily used for PNP accept by pni but is no longer needed/updated */ - @Deprecated - private static final String AUTH_SERVICE_ID = "auth_service_id"; - - - /* V2 Group columns */ - /** 32 bytes serialized {@link GroupMasterKey} */ - public static final String V2_MASTER_KEY = "master_key"; - /** Increments with every change to the group */ - private static final String V2_REVISION = "revision"; - /** Serialized {@link DecryptedGroup} protobuf */ - 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, " + - RECIPIENT_ID + " INTEGER, " + - TITLE + " TEXT, " + - MEMBERS + " TEXT, " + - AVATAR_ID + " INTEGER, " + - AVATAR_KEY + " BLOB, " + - AVATAR_CONTENT_TYPE + " TEXT, " + - AVATAR_RELAY + " TEXT, " + - TIMESTAMP + " INTEGER, " + - ACTIVE + " INTEGER DEFAULT 1, " + - AVATAR_DIGEST + " BLOB, " + - MMS + " INTEGER DEFAULT 0, " + - V2_MASTER_KEY + " BLOB, " + - V2_REVISION + " BLOB, " + - V2_DECRYPTED_GROUP + " BLOB, " + - EXPECTED_V2_ID + " TEXT DEFAULT NULL, " + - UNMIGRATED_V1_MEMBERS + " TEXT DEFAULT NULL, " + - DISTRIBUTION_ID + " TEXT DEFAULT NULL, " + - SHOW_AS_STORY_STATE + " INTEGER DEFAULT 0, " + - AUTH_SERVICE_ID + " TEXT DEFAULT NULL, " + - LAST_FORCE_UPDATE_TIMESTAMP + " INTEGER DEFAULT 0);"; - - public static final String[] CREATE_INDEXS = { - "CREATE UNIQUE INDEX IF NOT EXISTS group_id_index ON " + TABLE_NAME + " (" + GROUP_ID + ");", - "CREATE UNIQUE INDEX IF NOT EXISTS group_recipient_id_index ON " + TABLE_NAME + " (" + RECIPIENT_ID + ");", - "CREATE UNIQUE INDEX IF NOT EXISTS expected_v2_id_index ON " + TABLE_NAME + " (" + EXPECTED_V2_ID + ");", - "CREATE UNIQUE INDEX IF NOT EXISTS group_distribution_id_index ON " + TABLE_NAME + "(" + DISTRIBUTION_ID + ");" - }; - - private static final String[] GROUP_PROJECTION = { - GROUP_ID, RECIPIENT_ID, TITLE, MEMBERS, UNMIGRATED_V1_MEMBERS, AVATAR_ID, AVATAR_KEY, AVATAR_CONTENT_TYPE, AVATAR_RELAY, AVATAR_DIGEST, - TIMESTAMP, ACTIVE, MMS, V2_MASTER_KEY, V2_REVISION, V2_DECRYPTED_GROUP, LAST_FORCE_UPDATE_TIMESTAMP - }; - - static final List TYPED_GROUP_PROJECTION = Stream.of(GROUP_PROJECTION).filterNot(it -> it.equals(RECIPIENT_ID)).map(columnName -> TABLE_NAME + "." + columnName).toList(); - - public GroupTable(Context context, SignalDatabase databaseHelper) { - super(context, databaseHelper); - } - - public Optional getGroup(RecipientId recipientId) { - try (Cursor cursor = databaseHelper.getSignalReadableDatabase().query(TABLE_NAME, null, RECIPIENT_ID + " = ?", new String[] { recipientId.serialize()}, null, null, null)) { - if (cursor != null && cursor.moveToNext()) { - return getGroup(cursor); - } - - return Optional.empty(); - } - } - - public Optional getGroup(@NonNull GroupId groupId) { - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - - try (Cursor cursor = db.query(TABLE_NAME, null, GROUP_ID + " = ?", SqlUtil.buildArgs(groupId.toString()), null, null, null)) { - if (cursor != null && cursor.moveToNext()) { - Optional groupRecord = getGroup(cursor); - - if (groupRecord.isPresent() && RemappedRecords.getInstance().areAnyRemapped(groupRecord.get().getMembers())) { - String remaps = RemappedRecords.getInstance().buildRemapDescription(groupRecord.get().getMembers()); - Log.w(TAG, "Found a group with remapped recipients in it's membership list! Updating the list. GroupId: " + groupId + ", Remaps: " + remaps, true); - - Collection remapped = RemappedRecords.getInstance().remap(groupRecord.get().getMembers()); - - ContentValues values = new ContentValues(); - values.put(MEMBERS, RecipientId.toSerializedList(remapped)); - - if (db.update(TABLE_NAME, values, GROUP_ID + " = ?", SqlUtil.buildArgs(groupId)) > 0) { - return getGroup(groupId); - } else { - throw new IllegalStateException("Failed to update group with remapped recipients!"); - } - } - - return getGroup(cursor); - } - - return Optional.empty(); - } - } - - public boolean groupExists(@NonNull GroupId groupId) { - try (Cursor cursor = databaseHelper.getSignalReadableDatabase().query(TABLE_NAME, null, GROUP_ID + " = ?", - new String[] {groupId.toString()}, - null, null, null)) - { - return cursor.moveToNext(); - } - } - - /** - * @return A gv1 group whose expected v2 ID matches the one provided. - */ - public Optional getGroupV1ByExpectedV2(@NonNull GroupId.V2 gv2Id) { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - try (Cursor cursor = db.query(TABLE_NAME, GROUP_PROJECTION, EXPECTED_V2_ID + " = ?", SqlUtil.buildArgs(gv2Id), null, null, null)) { - if (cursor.moveToFirst()) { - return getGroup(cursor); - } else { - return Optional.empty(); - } - } - } - - public Optional getGroupByDistributionId(@NonNull DistributionId distributionId) { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - String query = DISTRIBUTION_ID + " = ?"; - String[] args = SqlUtil.buildArgs(distributionId); - - try (Cursor cursor = db.query(TABLE_NAME, null, query, args, null, null, null)) { - if (cursor.moveToFirst()) { - return getGroup(cursor); - } else { - return Optional.empty(); - } - } - } - - public void removeUnmigratedV1Members(@NonNull GroupId.V2 id) { - Optional group = getGroup(id); - - if (!group.isPresent()) { - Log.w(TAG, "Couldn't find the group!", new Throwable()); - return; - } - - removeUnmigratedV1Members(id, group.get().getUnmigratedV1Members()); - } - - /** - * Removes the specified members from the list of 'unmigrated V1 members' -- the list of members - * that were either dropped or had to be invited when migrating the group from V1->V2. - */ - public void removeUnmigratedV1Members(@NonNull GroupId.V2 id, @NonNull List toRemove) { - Optional group = getGroup(id); - - if (!group.isPresent()) { - Log.w(TAG, "Couldn't find the group!", new Throwable()); - return; - } - - List newUnmigrated = group.get().getUnmigratedV1Members(); - newUnmigrated.removeAll(toRemove); - - ContentValues values = new ContentValues(); - values.put(UNMIGRATED_V1_MEMBERS, newUnmigrated.isEmpty() ? null : RecipientId.toSerializedList(newUnmigrated)); - - databaseHelper.getSignalWritableDatabase().update(TABLE_NAME, values, GROUP_ID + " = ?", SqlUtil.buildArgs(id)); - - Recipient.live(Recipient.externalGroupExact(id).getId()).refresh(); - } - - Optional getGroup(Cursor cursor) { - Reader reader = new Reader(cursor); - return Optional.ofNullable(reader.getCurrent()); - } - - /** - * @return local db group revision or -1 if not present. - */ - public int getGroupV2Revision(@NonNull GroupId.V2 groupId) { - try (Cursor cursor = databaseHelper.getSignalReadableDatabase().query(TABLE_NAME, null, GROUP_ID + " = ?", - new String[] {groupId.toString()}, - null, null, null)) - { - if (cursor != null && cursor.moveToNext()) { - return cursor.getInt(cursor.getColumnIndexOrThrow(V2_REVISION)); - } - - return -1; - } - } - - /** - * Call if you are sure this group should exist. - *

- * Finds group and throws if it cannot. - */ - public @NonNull GroupRecord requireGroup(@NonNull GroupId groupId) { - Optional group = getGroup(groupId); - - if (!group.isPresent()) { - throw new AssertionError("Group not found"); - } - - return group.get(); - } - - public boolean isUnknownGroup(@NonNull GroupId groupId) { - Optional group = getGroup(groupId); - - if (!group.isPresent()) { - return true; - } - - boolean noMetadata = !group.get().hasAvatar() && TextUtils.isEmpty(group.get().getTitle()); - boolean noMembers = group.get().getMembers().isEmpty() || (group.get().getMembers().size() == 1 && group.get().getMembers().contains(Recipient.self().getId())); - - return noMetadata && noMembers; - } - - public Reader queryGroupsByTitle(String inputQuery, boolean includeInactive, boolean excludeV1, boolean excludeMms) { - SqlUtil.Query query = getGroupQueryWhereStatement(inputQuery, includeInactive, excludeV1, excludeMms); - Cursor cursor = databaseHelper.getSignalReadableDatabase().query(TABLE_NAME, null, query.getWhere(), query.getWhereArgs(), null, null, TITLE + " COLLATE NOCASE ASC"); - - return new Reader(cursor); - } - - public Reader queryGroupsByMembership(@NonNull Set recipientIds, boolean includeInactive, boolean excludeV1, boolean excludeMms) { - if (recipientIds.isEmpty()) { - return new Reader(null); - } - - if (recipientIds.size() > 30) { - Log.w(TAG, "[queryGroupsByMembership] Large set of recipientIds (" + recipientIds.size() + ")! Using the first 30."); - recipientIds = recipientIds.stream().limit(30).collect(Collectors.toSet()); - } - - List recipientLikeClauses = recipientIds.stream() - .map(RecipientId::toLong) - .map(id -> "(" + MEMBERS + " LIKE " + id + " || ',%' OR " + MEMBERS + " LIKE '%,' || " + id + " || ',%' OR " + MEMBERS + " LIKE '%,' || " + id + ")") - .collect(Collectors.toList()); - - String query; - String[] queryArgs; - - String membershipQuery = "(" + Util.join(recipientLikeClauses, " OR ") + ")"; - - if (includeInactive) { - query = membershipQuery + " AND (" + ACTIVE + " = ? OR " + RECIPIENT_ID + " IN (SELECT " + ThreadTable.RECIPIENT_ID + " FROM " + ThreadTable.TABLE_NAME + "))"; - queryArgs = SqlUtil.buildArgs(1); - } else { - query = membershipQuery + " AND " + ACTIVE + " = ?"; - queryArgs = SqlUtil.buildArgs(1); - } - - if (excludeV1) { - query += " AND " + EXPECTED_V2_ID + " IS NULL"; - } - - if (excludeMms) { - query += " AND " + MMS + " = 0"; - } - - return new Reader(getReadableDatabase().query(TABLE_NAME, null, query, queryArgs, null, null, null)); - } - - public Reader queryGroupsByRecency(@NonNull GroupQuery groupQuery) { - SqlUtil.Query query = getGroupQueryWhereStatement(groupQuery.searchQuery, groupQuery.includeInactive, !groupQuery.includeV1, !groupQuery.includeMms); - String sql = "SELECT * FROM " + TABLE_NAME + - " LEFT JOIN " + ThreadTable.TABLE_NAME + " ON " + GroupTable.TABLE_NAME + "." + RECIPIENT_ID + " = " + ThreadTable.TABLE_NAME + "." + ThreadTable.RECIPIENT_ID + - " WHERE " + query.getWhere() + - " ORDER BY " + ThreadTable.TABLE_NAME + "." + ThreadTable.DATE + " DESC"; - - return new Reader(databaseHelper.getSignalReadableDatabase().rawQuery(sql, query.getWhereArgs())); - } - - public Reader queryGroups(@NonNull GroupQuery groupQuery) { - if (groupQuery.sortOrder == ContactSearchSortOrder.NATURAL) { - return queryGroupsByTitle(groupQuery.searchQuery, groupQuery.includeInactive, !groupQuery.includeV1, !groupQuery.includeMms); - } else { - return queryGroupsByRecency(groupQuery); - } - } - - private @NonNull SqlUtil.Query getGroupQueryWhereStatement(String inputQuery, boolean includeInactive, boolean excludeV1, boolean excludeMms) { - String query; - String[] queryArgs; - - String caseInsensitiveQuery = SqlUtil.buildCaseInsensitiveGlobPattern(inputQuery); - - if (includeInactive) { - query = TITLE + " GLOB ? AND (" + ACTIVE + " = ? OR " + RECIPIENT_ID + " IN (SELECT " + ThreadTable.RECIPIENT_ID + " FROM " + ThreadTable.TABLE_NAME + "))"; - queryArgs = SqlUtil.buildArgs(caseInsensitiveQuery, 1); - } else { - query = TITLE + " GLOB ? AND " + ACTIVE + " = ?"; - queryArgs = SqlUtil.buildArgs(caseInsensitiveQuery, 1); - } - - if (excludeV1) { - query += " AND " + EXPECTED_V2_ID + " IS NULL"; - } - - if (excludeMms) { - query += " AND " + MMS + " = 0"; - } - - return new SqlUtil.Query(query, queryArgs); - } - - public @NonNull DistributionId getOrCreateDistributionId(@NonNull GroupId.V2 groupId) { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - String query = GROUP_ID + " = ?"; - String[] args = SqlUtil.buildArgs(groupId); - - try (Cursor cursor = databaseHelper.getSignalReadableDatabase().query(TABLE_NAME, new String[] { DISTRIBUTION_ID }, query, args, null, null, null)) { - if (cursor.moveToFirst()) { - Optional serialized = CursorUtil.getString(cursor, DISTRIBUTION_ID); - - if (serialized.isPresent()) { - return DistributionId.from(serialized.get()); - } else { - Log.w(TAG, "Missing distributionId! Creating one."); - - DistributionId distributionId = DistributionId.create(); - - ContentValues values = new ContentValues(1); - values.put(DISTRIBUTION_ID, distributionId.toString()); - - int count = db.update(TABLE_NAME, values, query, args); - if (count < 1) { - throw new IllegalStateException("Tried to create a distributionId for " + groupId + ", but it doesn't exist!"); - } - - return distributionId; - } - } else { - throw new IllegalStateException("Group " + groupId + " doesn't exist!"); - } - } - } - - public GroupId.Mms getOrCreateMmsGroupForMembers(List members) { - Collections.sort(members); - - Cursor cursor = databaseHelper.getSignalReadableDatabase().query(TABLE_NAME, new String[] { GROUP_ID}, - MEMBERS + " = ? AND " + MMS + " = ?", - new String[] {RecipientId.toSerializedList(members), "1"}, - null, null, null); - try { - if (cursor != null && cursor.moveToNext()) { - return GroupId.parseOrThrow(cursor.getString(cursor.getColumnIndexOrThrow(GROUP_ID))) - .requireMms(); - } else { - GroupId.Mms groupId = GroupId.createMms(new SecureRandom()); - create(groupId, null, members); - return groupId; - } - } finally { - if (cursor != null) cursor.close(); - } - } - - @WorkerThread - public List getPushGroupNamesContainingMember(@NonNull RecipientId recipientId) { - return Stream.of(getPushGroupsContainingMember(recipientId)) - .map(groupRecord -> Recipient.resolved(groupRecord.getRecipientId()).getDisplayName(context)) - .toList(); - } - - @WorkerThread - public @NonNull List getPushGroupsContainingMember(@NonNull RecipientId recipientId) { - return getGroupsContainingMember(recipientId, true); - } - - public @NonNull List getGroupsContainingMember(@NonNull RecipientId recipientId, boolean pushOnly) { - return getGroupsContainingMember(recipientId, pushOnly, false); - } - - @WorkerThread - public @NonNull List getGroupsContainingMember(@NonNull RecipientId recipientId, boolean pushOnly, boolean includeInactive) { - SQLiteDatabase database = databaseHelper.getSignalReadableDatabase(); - String table = TABLE_NAME + " INNER JOIN " + ThreadTable.TABLE_NAME + " ON " + TABLE_NAME + "." + RECIPIENT_ID + " = " + ThreadTable.TABLE_NAME + "." + ThreadTable.RECIPIENT_ID; - String query = MEMBERS + " LIKE ?"; - String[] args = SqlUtil.buildArgs("%" + recipientId.serialize() + "%"); - String orderBy = ThreadTable.TABLE_NAME + "." + ThreadTable.DATE + " DESC"; - - if (pushOnly) { - query += " AND " + MMS + " = ?"; - args = SqlUtil.appendArg(args, "0"); - } - - if (!includeInactive) { - query += " AND " + ACTIVE + " = ?"; - args = SqlUtil.appendArg(args, "1"); - } - - List groups = new LinkedList<>(); - - try (Cursor cursor = database.query(table, null, query, args, null, null, orderBy)) { - while (cursor != null && cursor.moveToNext()) { - String serializedMembers = cursor.getString(cursor.getColumnIndexOrThrow(MEMBERS)); - - if (RecipientId.serializedListContains(serializedMembers, recipientId)) { - groups.add(new Reader(cursor).getCurrent()); - } - } - } - - return groups; - } - - public Reader getGroups() { - @SuppressLint("Recycle") - Cursor cursor = databaseHelper.getSignalReadableDatabase().query(TABLE_NAME, null, null, null, null, null, null); - return new Reader(cursor); - } - - public int getActiveGroupCount() { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - String[] cols = { "COUNT(*)" }; - String query = ACTIVE + " = 1"; - - try (Cursor cursor = db.query(TABLE_NAME, cols, query, null, null, null, null)) { - if (cursor != null && cursor.moveToFirst()) { - return cursor.getInt(0); - } - } - - return 0; - } - - @WorkerThread - public @NonNull List getGroupMemberIds(@NonNull GroupId groupId, @NonNull MemberSet memberSet) { - if (groupId.isV2()) { - return getGroup(groupId).map(g -> g.requireV2GroupProperties().getMemberRecipientIds(memberSet)) - .orElse(Collections.emptyList()); - } else { - List currentMembers = getCurrentMembers(groupId); - - if (!memberSet.includeSelf) { - currentMembers.remove(Recipient.self().getId()); - } - - return currentMembers; - } - } - - @WorkerThread - public @NonNull List getGroupMembers(@NonNull GroupId groupId, @NonNull MemberSet memberSet) { - if (groupId.isV2()) { - return getGroup(groupId).map(g -> g.requireV2GroupProperties().getMemberRecipients(memberSet)) - .orElse(Collections.emptyList()); - } else { - List currentMembers = getCurrentMembers(groupId); - List recipients = new ArrayList<>(currentMembers.size()); - - for (RecipientId member : currentMembers) { - Recipient resolved = Recipient.resolved(member); - if (memberSet.includeSelf || !resolved.isSelf()) { - recipients.add(resolved); - } - } - - return recipients; - } - } - - public void create(@NonNull GroupId.V1 groupId, - @Nullable String title, - @NonNull Collection members, - @Nullable SignalServiceAttachmentPointer avatar, - @Nullable String relay) - { - if (groupExists(groupId.deriveV2MigrationGroupId())) { - throw new LegacyGroupInsertException(groupId); - } - create(groupId, title, members, avatar, relay, null, null); - } - - public void create(@NonNull GroupId.Mms groupId, - @Nullable String title, - @NonNull Collection members) - { - create(groupId, Util.isEmpty(title) ? null : title, members, null, null, null, null); - } - - public GroupId.V2 create(@NonNull GroupMasterKey groupMasterKey, - @NonNull DecryptedGroup groupState) - { - return create(groupMasterKey, groupState, false); - } - - public GroupId.V2 create(@NonNull GroupMasterKey groupMasterKey, - @NonNull DecryptedGroup groupState, - boolean force) - { - GroupId.V2 groupId = GroupId.v2(groupMasterKey); - - if (!force && getGroupV1ByExpectedV2(groupId).isPresent()) { - throw new MissedGroupMigrationInsertException(groupId); - } else if (force) { - Log.w(TAG, "Forcing the creation of a group even though we already have a V1 ID!"); - } - - create(groupId, groupState.getTitle(), Collections.emptyList(), null, null, groupMasterKey, groupState); - - return groupId; - } - - /** - * There was a point in time where we weren't properly responding to group creates on linked devices. This would result in us having a Recipient entry for the - * group, but we'd either be missing the group entry, or that entry would be missing a master key. This method fixes this scenario. - */ - public void fixMissingMasterKey(@Nullable ServiceId authServiceId, @NonNull GroupMasterKey groupMasterKey) { - GroupId.V2 groupId = GroupId.v2(groupMasterKey); - - if (getGroupV1ByExpectedV2(groupId).isPresent()) { - Log.w(TAG, "There already exists a V1 group that should be migrated into this group. But if the recipient already exists, there's not much we can do here."); - } - - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - - db.beginTransaction(); - try { - String query = GROUP_ID + " = ?"; - String[] args = SqlUtil.buildArgs(groupId); - ContentValues values = new ContentValues(); - - values.put(V2_MASTER_KEY, groupMasterKey.serialize()); - - int updated = db.update(TABLE_NAME, values, query, args); - - if (updated < 1) { - Log.w(TAG, "No group entry. Creating restore placeholder for " + groupId); - create( - groupMasterKey, - DecryptedGroup.newBuilder() - .setRevision(GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION) - .build(), - true); - } else { - Log.w(TAG, "Had a group entry, but it was missing a master key. Updated."); - } - - db.setTransactionSuccessful(); - } finally { - db.endTransaction(); - } - - Log.w(TAG, "Scheduling request for latest group info for " + groupId); - ApplicationDependencies.getJobManager().add(new RequestGroupV2InfoJob(groupId)); - } - - /** - * @param groupMasterKey null for V1, must be non-null for V2 (presence dictates group version). - */ - private void create(@NonNull GroupId groupId, - @Nullable String title, - @NonNull Collection memberCollection, - @Nullable SignalServiceAttachmentPointer avatar, - @Nullable String relay, - @Nullable GroupMasterKey groupMasterKey, - @Nullable DecryptedGroup groupState) - { - RecipientTable recipientTable = SignalDatabase.recipients(); - RecipientId groupRecipientId = recipientTable.getOrInsertFromGroupId(groupId); - List members = new ArrayList<>(new HashSet<>(memberCollection)); - - Collections.sort(members); - - ContentValues contentValues = new ContentValues(); - contentValues.put(RECIPIENT_ID, groupRecipientId.serialize()); - contentValues.put(GROUP_ID, groupId.toString()); - contentValues.put(TITLE, title); - contentValues.put(MEMBERS, RecipientId.toSerializedList(members)); - - if (avatar != null) { - contentValues.put(AVATAR_ID, avatar.getRemoteId().getV2().get()); - contentValues.put(AVATAR_KEY, avatar.getKey()); - contentValues.put(AVATAR_CONTENT_TYPE, avatar.getContentType()); - contentValues.put(AVATAR_DIGEST, avatar.getDigest().orElse(null)); - } else { - contentValues.put(AVATAR_ID, 0); - } - - contentValues.put(AVATAR_RELAY, relay); - contentValues.put(TIMESTAMP, System.currentTimeMillis()); - - if (groupId.isV2()) { - contentValues.put(ACTIVE, groupState != null && gv2GroupActive(groupState) ? 1 : 0); - contentValues.put(DISTRIBUTION_ID, DistributionId.create().toString()); - } else if (groupId.isV1()) { - contentValues.put(ACTIVE, 1); - contentValues.put(EXPECTED_V2_ID, groupId.requireV1().deriveV2MigrationGroupId().toString()); - } else { - contentValues.put(ACTIVE, 1); - } - - contentValues.put(MMS, groupId.isMms()); - - List groupMembers = members; - if (groupMasterKey != null) { - if (groupState == null) { - throw new AssertionError("V2 master key but no group state"); - } - groupId.requireV2(); - groupMembers = getV2GroupMembers(groupState, true); - contentValues.put(V2_MASTER_KEY, groupMasterKey.serialize()); - contentValues.put(V2_REVISION, groupState.getRevision()); - contentValues.put(V2_DECRYPTED_GROUP, groupState.toByteArray()); - contentValues.put(MEMBERS, RecipientId.toSerializedList(groupMembers)); - } else { - if (groupId.isV2()) { - throw new AssertionError("V2 group id but no master key"); - } - } - - databaseHelper.getSignalWritableDatabase().insert(TABLE_NAME, null, contentValues); - - if (groupState != null && groupState.hasDisappearingMessagesTimer()) { - recipientTable.setExpireMessages(groupRecipientId, groupState.getDisappearingMessagesTimer().getDuration()); - } - - if (groupMembers != null && (groupId.isMms() || Recipient.resolved(groupRecipientId).isProfileSharing())) { - recipientTable.setHasGroupsInCommon(groupMembers); - } - - Recipient.live(groupRecipientId).refresh(); - - notifyConversationListListeners(); - } - - public void update(@NonNull GroupId.V1 groupId, - @Nullable String title, - @Nullable SignalServiceAttachmentPointer avatar) - { - ContentValues contentValues = new ContentValues(); - if (title != null) contentValues.put(TITLE, title); - - if (avatar != null) { - contentValues.put(AVATAR_ID, avatar.getRemoteId().getV2().get()); - contentValues.put(AVATAR_CONTENT_TYPE, avatar.getContentType()); - contentValues.put(AVATAR_KEY, avatar.getKey()); - contentValues.put(AVATAR_DIGEST, avatar.getDigest().orElse(null)); - } else { - contentValues.put(AVATAR_ID, 0); - } - - databaseHelper.getSignalWritableDatabase().update(TABLE_NAME, contentValues, - GROUP_ID + " = ?", - new String[] {groupId.toString()}); - - RecipientId groupRecipient = SignalDatabase.recipients().getOrInsertFromGroupId(groupId); - Recipient.live(groupRecipient).refresh(); - - notifyConversationListListeners(); - } - - /** - * Migrates a V1 group to a V2 group. - * - * @param decryptedGroup The state that represents the group on the server. This will be used to - * determine if we need to save our old membership list and stuff. - */ - public @NonNull GroupId.V2 migrateToV2(long threadId, - @NonNull GroupId.V1 groupIdV1, - @NonNull DecryptedGroup decryptedGroup) - { - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - GroupId.V2 groupIdV2 = groupIdV1.deriveV2MigrationGroupId(); - GroupMasterKey groupMasterKey = groupIdV1.deriveV2MigrationMasterKey(); - - db.beginTransaction(); - try { - GroupRecord record = getGroup(groupIdV1).get(); - - ContentValues contentValues = new ContentValues(); - contentValues.put(GROUP_ID, groupIdV2.toString()); - contentValues.put(V2_MASTER_KEY, groupMasterKey.serialize()); - contentValues.put(DISTRIBUTION_ID, DistributionId.create().toString()); - contentValues.putNull(EXPECTED_V2_ID); - - List newMembers = uuidsToRecipientIds(DecryptedGroupUtil.membersToUuidList(decryptedGroup.getMembersList())); - List pendingMembers = uuidsToRecipientIds(DecryptedGroupUtil.pendingToUuidList(decryptedGroup.getPendingMembersList())); - - newMembers.addAll(pendingMembers); - - List droppedMembers = new ArrayList<>(SetUtil.difference(record.getMembers(), newMembers)); - List unmigratedMembers = Util.concatenatedList(pendingMembers, droppedMembers); - - contentValues.put(UNMIGRATED_V1_MEMBERS, unmigratedMembers.isEmpty() ? null : RecipientId.toSerializedList(unmigratedMembers)); - - int updated = db.update(TABLE_NAME, contentValues, GROUP_ID + " = ?", SqlUtil.buildArgs(groupIdV1.toString())); - - if (updated != 1) { - throw new AssertionError(); - } - - SignalDatabase.recipients().updateGroupId(groupIdV1, groupIdV2); - - update(groupMasterKey, decryptedGroup); - - SignalDatabase.messages().insertGroupV1MigrationEvents(record.getRecipientId(), - threadId, - new GroupMigrationMembershipChange(pendingMembers, droppedMembers)); - - db.setTransactionSuccessful(); - } finally { - db.endTransaction(); - } - - return groupIdV2; - } - - public void update(@NonNull GroupMasterKey groupMasterKey, @NonNull DecryptedGroup decryptedGroup) { - update(GroupId.v2(groupMasterKey), decryptedGroup); - } - - public void update(@NonNull GroupId.V2 groupId, @NonNull DecryptedGroup decryptedGroup) { - RecipientTable recipientTable = SignalDatabase.recipients(); - RecipientId groupRecipientId = recipientTable.getOrInsertFromGroupId(groupId); - Optional existingGroup = getGroup(groupId); - String title = decryptedGroup.getTitle(); - ContentValues contentValues = new ContentValues(); - - if (existingGroup.isPresent() && existingGroup.get().getUnmigratedV1Members().size() > 0 && existingGroup.get().isV2Group()) { - Set unmigratedV1Members = new HashSet<>(existingGroup.get().getUnmigratedV1Members()); - - DecryptedGroupChange change = GroupChangeReconstruct.reconstructGroupChange(existingGroup.get().requireV2GroupProperties().getDecryptedGroup(), decryptedGroup); - - List addedMembers = uuidsToRecipientIds(DecryptedGroupUtil.membersToUuidList(change.getNewMembersList())); - List removedMembers = uuidsToRecipientIds(DecryptedGroupUtil.removedMembersUuidList(change)); - List addedInvites = uuidsToRecipientIds(DecryptedGroupUtil.pendingToUuidList(change.getNewPendingMembersList())); - List removedInvites = uuidsToRecipientIds(DecryptedGroupUtil.removedPendingMembersUuidList(change)); - List acceptedInvites = uuidsToRecipientIds(DecryptedGroupUtil.membersToUuidList(change.getPromotePendingMembersList())); - - unmigratedV1Members.removeAll(addedMembers); - unmigratedV1Members.removeAll(removedMembers); - unmigratedV1Members.removeAll(addedInvites); - unmigratedV1Members.removeAll(removedInvites); - unmigratedV1Members.removeAll(acceptedInvites); - - contentValues.put(UNMIGRATED_V1_MEMBERS, unmigratedV1Members.isEmpty() ? null : RecipientId.toSerializedList(unmigratedV1Members)); - } - - List groupMembers = getV2GroupMembers(decryptedGroup, true); - contentValues.put(TITLE, title); - contentValues.put(V2_REVISION, decryptedGroup.getRevision()); - contentValues.put(V2_DECRYPTED_GROUP, decryptedGroup.toByteArray()); - contentValues.put(MEMBERS, RecipientId.toSerializedList(groupMembers)); - contentValues.put(ACTIVE, gv2GroupActive(decryptedGroup) ? 1 : 0); - - DistributionId distributionId = Objects.requireNonNull(existingGroup.get().getDistributionId()); - - if (existingGroup.isPresent() && existingGroup.get().isV2Group()) { - DecryptedGroupChange change = GroupChangeReconstruct.reconstructGroupChange(existingGroup.get().requireV2GroupProperties().getDecryptedGroup(), decryptedGroup); - List removed = DecryptedGroupUtil.removedMembersUuidList(change); - - if (removed.size() > 0) { - Log.i(TAG, removed.size() + " members were removed from group " + groupId + ". Rotating the DistributionId " + distributionId); - SenderKeyUtil.rotateOurKey(distributionId); - } - } - - databaseHelper.getSignalWritableDatabase().update(TABLE_NAME, contentValues, - GROUP_ID + " = ?", - new String[]{ groupId.toString() }); - - if (decryptedGroup.hasDisappearingMessagesTimer()) { - recipientTable.setExpireMessages(groupRecipientId, decryptedGroup.getDisappearingMessagesTimer().getDuration()); - } - - if (groupId.isMms() || Recipient.resolved(groupRecipientId).isProfileSharing()) { - recipientTable.setHasGroupsInCommon(groupMembers); - } - - Recipient.live(groupRecipientId).refresh(); - - notifyConversationListListeners(); - } - - public void updateTitle(@NonNull GroupId.V1 groupId, String title) { - updateTitle((GroupId) groupId, title); - } - - public void updateTitle(@NonNull GroupId.Mms groupId, @Nullable String title) { - updateTitle((GroupId) groupId, Util.isEmpty(title) ? null : title); - } - - private void updateTitle(@NonNull GroupId groupId, String title) { - if (!groupId.isV1() && !groupId.isMms()) { - throw new AssertionError(); - } - - ContentValues contentValues = new ContentValues(); - contentValues.put(TITLE, title); - databaseHelper.getSignalWritableDatabase().update(TABLE_NAME, contentValues, GROUP_ID + " = ?", - new String[] {groupId.toString()}); - - RecipientId groupRecipient = SignalDatabase.recipients().getOrInsertFromGroupId(groupId); - Recipient.live(groupRecipient).refresh(); - } - - /** - * Used to bust the Glide cache when an avatar changes. - */ - public void onAvatarUpdated(@NonNull GroupId groupId, boolean hasAvatar) { - ContentValues contentValues = new ContentValues(1); - contentValues.put(AVATAR_ID, hasAvatar ? Math.abs(new SecureRandom().nextLong()) : 0); - - databaseHelper.getSignalWritableDatabase().update(TABLE_NAME, contentValues, GROUP_ID + " = ?", - new String[] {groupId.toString()}); - - RecipientId groupRecipient = SignalDatabase.recipients().getOrInsertFromGroupId(groupId); - Recipient.live(groupRecipient).refresh(); - } - - public void updateMembers(@NonNull GroupId groupId, List members) { - Collections.sort(members); - - ContentValues contents = new ContentValues(); - contents.put(MEMBERS, RecipientId.toSerializedList(members)); - contents.put(ACTIVE, 1); - - databaseHelper.getSignalWritableDatabase().update(TABLE_NAME, contents, GROUP_ID + " = ?", - new String[] {groupId.toString()}); - - RecipientId groupRecipient = SignalDatabase.recipients().getOrInsertFromGroupId(groupId); - Recipient.live(groupRecipient).refresh(); - } - - public void remove(@NonNull GroupId groupId, RecipientId source) { - List currentMembers = getCurrentMembers(groupId); - currentMembers.remove(source); - - ContentValues contents = new ContentValues(); - contents.put(MEMBERS, RecipientId.toSerializedList(currentMembers)); - - databaseHelper.getSignalWritableDatabase().update(TABLE_NAME, contents, GROUP_ID + " = ?", - new String[] {groupId.toString()}); - - RecipientId groupRecipient = SignalDatabase.recipients().getOrInsertFromGroupId(groupId); - Recipient.live(groupRecipient).refresh(); - } - - private static boolean gv2GroupActive(@NonNull DecryptedGroup decryptedGroup) { - ACI aci = SignalStore.account().requireAci(); - - return DecryptedGroupUtil.findMemberByUuid(decryptedGroup.getMembersList(), aci.uuid()).isPresent() || - DecryptedGroupUtil.findPendingByUuid(decryptedGroup.getPendingMembersList(), aci.uuid()).isPresent(); - } - - private List getCurrentMembers(@NonNull GroupId groupId) { - Cursor cursor = null; - - try { - cursor = databaseHelper.getSignalReadableDatabase().query(TABLE_NAME, new String[] { MEMBERS}, - GROUP_ID + " = ?", - new String[] {groupId.toString()}, - null, null, null); - - if (cursor != null && cursor.moveToFirst()) { - String serializedMembers = cursor.getString(cursor.getColumnIndexOrThrow(MEMBERS)); - return RecipientId.fromSerializedList(serializedMembers); - } - - return new LinkedList<>(); - } finally { - if (cursor != null) - cursor.close(); - } - } - - public boolean isActive(@NonNull GroupId groupId) { - Optional record = getGroup(groupId); - return record.isPresent() && record.get().isActive(); - } - - public void setActive(@NonNull GroupId groupId, boolean active) { - SQLiteDatabase database = databaseHelper.getSignalWritableDatabase(); - ContentValues values = new ContentValues(); - values.put(ACTIVE, active ? 1 : 0); - database.update(TABLE_NAME, values, GROUP_ID + " = ?", new String[] {groupId.toString()}); - } - - public void setLastForceUpdateTimestamp(@NonNull GroupId groupId, long timestamp) { - ContentValues values = new ContentValues(); - values.put(LAST_FORCE_UPDATE_TIMESTAMP, timestamp); - getWritableDatabase().update(TABLE_NAME, values, GROUP_ID + " = ?", SqlUtil.buildArgs(groupId)); - } - - @WorkerThread - public boolean isCurrentMember(@NonNull GroupId.Push groupId, @NonNull RecipientId recipientId) { - SQLiteDatabase database = databaseHelper.getSignalReadableDatabase(); - - try (Cursor cursor = database.query(TABLE_NAME, new String[] {MEMBERS}, - GROUP_ID + " = ?", new String[] {groupId.toString()}, - null, null, null)) - { - if (cursor.moveToNext()) { - String serializedMembers = cursor.getString(cursor.getColumnIndexOrThrow(MEMBERS)); - return RecipientId.serializedListContains(serializedMembers, recipientId); - } else { - return false; - } - } - } - - - private static @NonNull List uuidsToRecipientIds(@NonNull List uuids) { - List groupMembers = new ArrayList<>(uuids.size()); - - for (UUID uuid : uuids) { - if (UuidUtil.UNKNOWN_UUID.equals(uuid)) { - Log.w(TAG, "Seen unknown UUID in members list"); - } else { - RecipientId id = RecipientId.from(ServiceId.from(uuid)); - Optional remapped = RemappedRecords.getInstance().getRecipient(id); - - if (remapped.isPresent()) { - Log.w(TAG, "Saw that " + id + " remapped to " + remapped + ". Using the mapping."); - groupMembers.add(remapped.get()); - } else { - groupMembers.add(id); - } - } - } - - Collections.sort(groupMembers); - - return groupMembers; - } - - private static @NonNull List getV2GroupMembers(@NonNull DecryptedGroup decryptedGroup, boolean shouldRetry) { - List uuids = DecryptedGroupUtil.membersToUuidList(decryptedGroup.getMembersList()); - List ids = uuidsToRecipientIds(uuids); - - if (RemappedRecords.getInstance().areAnyRemapped(ids)) { - if (shouldRetry) { - Log.w(TAG, "Found remapped records where we shouldn't. Clearing cache and trying again."); - RecipientId.clearCache(); - RemappedRecords.getInstance().resetCache(); - return getV2GroupMembers(decryptedGroup, false); - } else { - throw new IllegalStateException("Remapped records in group membership!"); - } - } else { - return ids; - } - } - - public @NonNull List getAllGroupV2Ids() { - List result = new LinkedList<>(); - - try (Cursor cursor = databaseHelper.getSignalReadableDatabase().query(TABLE_NAME, new String[]{ GROUP_ID }, null, null, null, null, null)) { - while (cursor.moveToNext()) { - GroupId groupId = GroupId.parseOrThrow(cursor.getString(cursor.getColumnIndexOrThrow(GROUP_ID))); - if (groupId.isV2()) { - result.add(groupId.requireV2()); - } - } - } - - return result; - } - - /** - * Key: The 'expected' V2 ID (i.e. what a V1 ID would map to when migrated) - * Value: The matching V1 ID - */ - public @NonNull Map getAllExpectedV2Ids() { - Map result = new HashMap<>(); - - String[] projection = new String[]{ GROUP_ID, EXPECTED_V2_ID }; - String query = EXPECTED_V2_ID + " NOT NULL"; - - try (Cursor cursor = databaseHelper.getSignalReadableDatabase().query(TABLE_NAME, projection, query, null, null, null, null)) { - while (cursor.moveToNext()) { - GroupId.V1 groupId = GroupId.parseOrThrow(cursor.getString(cursor.getColumnIndexOrThrow(GROUP_ID))).requireV1(); - GroupId.V2 expectedId = GroupId.parseOrThrow(cursor.getString(cursor.getColumnIndexOrThrow(EXPECTED_V2_ID))).requireV2(); - - result.put(expectedId, groupId); - } - } - - return result; - } - - @Override - public void remapRecipient(@NonNull RecipientId fromId, @NonNull RecipientId toId) { - for (GroupRecord group : getGroupsContainingMember(fromId, false, true)) { - Set newMembers = new LinkedHashSet<>(group.getMembers()); - newMembers.remove(fromId); - newMembers.add(toId); - - ContentValues groupValues = new ContentValues(); - groupValues.put(GroupTable.MEMBERS, RecipientId.toSerializedList(newMembers)); - - getWritableDatabase().update(GroupTable.TABLE_NAME, groupValues, GroupTable.RECIPIENT_ID + " = ?", SqlUtil.buildArgs(group.recipientId)); - - if (group.isV2Group()) { - removeUnmigratedV1Members(group.id.requireV2(), Collections.singletonList(fromId)); - } - } - } - - public static class Reader implements Closeable, ContactSearchIterator { - - public final Cursor cursor; - - public Reader(Cursor cursor) { - this.cursor = cursor; - } - - public @Nullable GroupRecord getNext() { - if (cursor == null || !cursor.moveToNext()) { - return null; - } - - return getCurrent(); - } - - public int getCount() { - if (cursor == null) { - return 0; - } else { - return cursor.getCount(); - } - } - - public @Nullable GroupRecord getCurrent() { - if (cursor == null || cursor.getString(cursor.getColumnIndexOrThrow(GROUP_ID)) == null || cursor.getLong(cursor.getColumnIndexOrThrow(RECIPIENT_ID)) == 0) { - return null; - } - - return new GroupRecord(GroupId.parseOrThrow(CursorUtil.requireString(cursor, GROUP_ID)), - RecipientId.from(CursorUtil.requireString(cursor, RECIPIENT_ID)), - CursorUtil.requireString(cursor, TITLE), - CursorUtil.requireString(cursor, MEMBERS), - CursorUtil.requireString(cursor, UNMIGRATED_V1_MEMBERS), - CursorUtil.requireLong(cursor, AVATAR_ID), - CursorUtil.requireBlob(cursor, AVATAR_KEY), - CursorUtil.requireString(cursor, AVATAR_CONTENT_TYPE), - CursorUtil.requireString(cursor, AVATAR_RELAY), - CursorUtil.requireBoolean(cursor, ACTIVE), - CursorUtil.requireBlob(cursor, AVATAR_DIGEST), - CursorUtil.requireBoolean(cursor, MMS), - CursorUtil.requireBlob(cursor, V2_MASTER_KEY), - CursorUtil.requireInt(cursor, V2_REVISION), - CursorUtil.requireBlob(cursor, V2_DECRYPTED_GROUP), - CursorUtil.getString(cursor, DISTRIBUTION_ID).map(DistributionId::from).orElse(null), - CursorUtil.requireLong(cursor, LAST_FORCE_UPDATE_TIMESTAMP)); - } - - @Override - public void close() { - if (this.cursor != null) - this.cursor.close(); - } - - @Override - public void moveToPosition(int n) { - cursor.moveToPosition(n); - } - - @Override - public boolean hasNext() { - return !cursor.isLast() && !cursor.isAfterLast(); - } - - @Override - public GroupRecord next() { - return getNext(); - } - } - - public static class GroupRecord { - - private final GroupId id; - private final RecipientId recipientId; - private final String title; - private final List members; - private final List unmigratedV1Members; - private final long avatarId; - private final byte[] avatarKey; - private final byte[] avatarDigest; - private final String avatarContentType; - private final String relay; - private final boolean active; - private final boolean mms; - @Nullable private final V2GroupProperties v2GroupProperties; - private final DistributionId distributionId; - private final long lastForceUpdateTimestamp; - - public GroupRecord(@NonNull GroupId id, - @NonNull RecipientId recipientId, - String title, - String members, - @Nullable String unmigratedV1Members, - long avatarId, - byte[] avatarKey, - String avatarContentType, - String relay, - boolean active, - byte[] avatarDigest, - boolean mms, - @Nullable byte[] groupMasterKeyBytes, - int groupRevision, - @Nullable byte[] decryptedGroupBytes, - @Nullable DistributionId distributionId, - long lastForceUpdateTimestamp) - { - this.id = id; - this.recipientId = recipientId; - this.title = title; - this.avatarId = avatarId; - this.avatarKey = avatarKey; - this.avatarDigest = avatarDigest; - this.avatarContentType = avatarContentType; - this.relay = relay; - this.active = active; - this.mms = mms; - this.distributionId = distributionId; - this.lastForceUpdateTimestamp = lastForceUpdateTimestamp; - - V2GroupProperties v2GroupProperties = null; - if (groupMasterKeyBytes != null && decryptedGroupBytes != null) { - GroupMasterKey groupMasterKey; - try { - groupMasterKey = new GroupMasterKey(groupMasterKeyBytes); - } catch (InvalidInputException e) { - throw new AssertionError(e); - } - v2GroupProperties = new V2GroupProperties(groupMasterKey, groupRevision, decryptedGroupBytes); - } - this.v2GroupProperties = v2GroupProperties; - - if (!TextUtils.isEmpty(members)) { - this.members = RecipientId.fromSerializedList(members); - } else { - this.members = Collections.emptyList(); - } - - if (!TextUtils.isEmpty(unmigratedV1Members)) { - this.unmigratedV1Members = RecipientId.fromSerializedList(unmigratedV1Members); - } else { - this.unmigratedV1Members = Collections.emptyList(); - } - } - - public GroupId getId() { - return id; - } - - public @NonNull RecipientId getRecipientId() { - return recipientId; - } - - public String getTitle() { - return title; - } - - public @NonNull String getDescription() { - if (v2GroupProperties != null) { - return v2GroupProperties.getDecryptedGroup().getDescription(); - } else { - return ""; - } - } - - public boolean isAnnouncementGroup() { - if (v2GroupProperties != null) { - return v2GroupProperties.getDecryptedGroup().getIsAnnouncementGroup() == EnabledState.ENABLED; - } else { - return false; - } - } - - public @NonNull List getMembers() { - return members; - } - - @WorkerThread - public @NonNull List getAdmins() { - if (v2GroupProperties != null) { - return v2GroupProperties.getAdmins(members.stream().map(Recipient::resolved).collect(Collectors.toList())); - } else { - return Collections.emptyList(); - } - } - - /** V1 members that were lost during the V1->V2 migration */ - public @NonNull List getUnmigratedV1Members() { - return unmigratedV1Members; - } - - public boolean hasAvatar() { - return avatarId != 0; - } - - public long getAvatarId() { - return avatarId; - } - - public byte[] getAvatarKey() { - return avatarKey; - } - - public byte[] getAvatarDigest() { - return avatarDigest; - } - - public String getAvatarContentType() { - return avatarContentType; - } - - public String getRelay() { - return relay; - } - - public boolean isActive() { - return active; - } - - public boolean isMms() { - return mms; - } - - public @Nullable DistributionId getDistributionId() { - return distributionId; - } - - public long getLastForceUpdateTimestamp() { - return lastForceUpdateTimestamp; - } - - public boolean isV1Group() { - return !mms && !isV2Group(); - } - - public boolean isV2Group() { - return v2GroupProperties != null; - } - - public @NonNull V2GroupProperties requireV2GroupProperties() { - if (v2GroupProperties == null) { - throw new AssertionError(); - } - - return v2GroupProperties; - } - - public boolean isAdmin(@NonNull Recipient recipient) { - return isV2Group() && requireV2GroupProperties().isAdmin(recipient); - } - - public MemberLevel memberLevel(@NonNull Recipient recipient) { - if (isV2Group()) { - MemberLevel memberLevel = requireV2GroupProperties().memberLevel(recipient.getServiceId()); - if (recipient.isSelf() && memberLevel == MemberLevel.NOT_A_MEMBER) { - memberLevel = requireV2GroupProperties().memberLevel(Optional.ofNullable(SignalStore.account().getPni())); - } - return memberLevel; - } else if (isMms() && recipient.isSelf()) { - return MemberLevel.FULL_MEMBER; - } else { - return members.contains(recipient.getId()) ? MemberLevel.FULL_MEMBER - : MemberLevel.NOT_A_MEMBER; - } - } - - /** - * Who is allowed to add to the membership of this group. - */ - public GroupAccessControl getMembershipAdditionAccessControl() { - if (isV2Group()) { - if (requireV2GroupProperties().getDecryptedGroup().getAccessControl().getMembers() == AccessControl.AccessRequired.MEMBER) { - return GroupAccessControl.ALL_MEMBERS; - } - return GroupAccessControl.ONLY_ADMINS; - } else if (isV1Group()) { - return GroupAccessControl.NO_ONE; - } else { - return id.isV1() ? GroupAccessControl.ALL_MEMBERS : GroupAccessControl.ONLY_ADMINS; - } - } - - /** - * Who is allowed to modify the attributes of this group, name/avatar/timer etc. - */ - public GroupAccessControl getAttributesAccessControl() { - if (isV2Group()) { - if (requireV2GroupProperties().getDecryptedGroup().getAccessControl().getAttributes() == AccessControl.AccessRequired.MEMBER) { - return GroupAccessControl.ALL_MEMBERS; - } - return GroupAccessControl.ONLY_ADMINS; - } else if (isV1Group()) { - return GroupAccessControl.NO_ONE; - } else { - return GroupAccessControl.ALL_MEMBERS; - } - } - - /** - * Whether or not the recipient is a pending member. - */ - public boolean isPendingMember(@NonNull Recipient recipient) { - if (isV2Group()) { - Optional serviceId = recipient.getServiceId(); - if (serviceId.isPresent()) { - return DecryptedGroupUtil.findPendingByUuid(requireV2GroupProperties().getDecryptedGroup().getPendingMembersList(), serviceId.get().uuid()) - .isPresent(); - } - } - return false; - } - } - - public static class V2GroupProperties { - - @NonNull private final GroupMasterKey groupMasterKey; - private final int groupRevision; - @NonNull private final byte[] decryptedGroupBytes; - private DecryptedGroup decryptedGroup; - - private V2GroupProperties(@NonNull GroupMasterKey groupMasterKey, int groupRevision, @NonNull byte[] decryptedGroup) { - this.groupMasterKey = groupMasterKey; - this.groupRevision = groupRevision; - this.decryptedGroupBytes = decryptedGroup; - } - - public @NonNull GroupMasterKey getGroupMasterKey() { - return groupMasterKey; - } - - public int getGroupRevision() { - return groupRevision; - } - - public @NonNull DecryptedGroup getDecryptedGroup() { - try { - if (decryptedGroup == null) { - decryptedGroup = DecryptedGroup.parseFrom(decryptedGroupBytes); - } - return decryptedGroup; - } catch (InvalidProtocolBufferException e) { - throw new AssertionError(e); - } - } - - public boolean isAdmin(@NonNull Recipient recipient) { - Optional serviceId = recipient.getServiceId(); - - if (!serviceId.isPresent()) { - return false; - } - - return DecryptedGroupUtil.findMemberByUuid(getDecryptedGroup().getMembersList(), serviceId.get().uuid()) - .map(t -> t.getRole() == Member.Role.ADMINISTRATOR) - .orElse(false); - } - - public @NonNull List getAdmins(@NonNull List members) { - return members.stream().filter(this::isAdmin).collect(Collectors.toList()); - } - - public MemberLevel memberLevel(@NonNull Optional serviceId) { - if (!serviceId.isPresent()) { - return MemberLevel.NOT_A_MEMBER; - } - - DecryptedGroup decryptedGroup = getDecryptedGroup(); - - return DecryptedGroupUtil.findMemberByUuid(decryptedGroup.getMembersList(), serviceId.get().uuid()) - .map(member -> member.getRole() == Member.Role.ADMINISTRATOR - ? MemberLevel.ADMINISTRATOR - : MemberLevel.FULL_MEMBER) - .orElse(DecryptedGroupUtil.findPendingByUuid(decryptedGroup.getPendingMembersList(), serviceId.get().uuid()) - .map(m -> MemberLevel.PENDING_MEMBER) - .orElse(DecryptedGroupUtil.findRequestingByUuid(decryptedGroup.getRequestingMembersList(), serviceId.get().uuid()) - .map(m -> MemberLevel.REQUESTING_MEMBER) - .orElse(MemberLevel.NOT_A_MEMBER))); - } - - public List getMemberRecipients(@NonNull MemberSet memberSet) { - return Recipient.resolvedList(getMemberRecipientIds(memberSet)); - } - - public List getMemberRecipientIds(@NonNull MemberSet memberSet) { - boolean includeSelf = memberSet.includeSelf; - DecryptedGroup groupV2 = getDecryptedGroup(); - UUID selfUuid = SignalStore.account().requireAci().uuid(); - List recipients = new ArrayList<>(groupV2.getMembersCount() + groupV2.getPendingMembersCount()); - int unknownMembers = 0; - int unknownPending = 0; - - for (UUID uuid : DecryptedGroupUtil.toUuidList(groupV2.getMembersList())) { - if (UuidUtil.UNKNOWN_UUID.equals(uuid)) { - unknownMembers++; - } else if (includeSelf || !selfUuid.equals(uuid)) { - recipients.add(RecipientId.from(ServiceId.from(uuid))); - } - } - if (memberSet.includePending) { - for (UUID uuid : DecryptedGroupUtil.pendingToUuidList(groupV2.getPendingMembersList())) { - if (UuidUtil.UNKNOWN_UUID.equals(uuid)) { - unknownPending++; - } else if (includeSelf || !selfUuid.equals(uuid)) { - recipients.add(RecipientId.from(ServiceId.from(uuid))); - } - } - } - - if ((unknownMembers + unknownPending) > 0) { - Log.w(TAG, String.format(Locale.US, "Group contains %d + %d unknown pending and full members", unknownPending, unknownMembers)); - } - - return recipients; - } - - public @NonNull Set getBannedMembers() { - return DecryptedGroupUtil.bannedMembersToUuidSet(getDecryptedGroup().getBannedMembersList()); - } - } - - public @NonNull List getGroupsToDisplayAsStories() throws BadGroupIdException { - String query = "SELECT " + GROUP_ID + ", (" + - "SELECT " + MessageTable.TABLE_NAME + "." + MessageTable.DATE_RECEIVED + " FROM " + MessageTable.TABLE_NAME + - " WHERE " + MessageTable.TABLE_NAME + "." + MessageTable.RECIPIENT_ID + " = " + ThreadTable.TABLE_NAME + "." + ThreadTable.RECIPIENT_ID + - " AND " + MessageTable.STORY_TYPE + " > 1 ORDER BY " + MessageTable.TABLE_NAME + "." + MessageTable.DATE_RECEIVED + " DESC LIMIT 1" + - ") as active_timestamp" + - " FROM " + TABLE_NAME + - " INNER JOIN " + ThreadTable.TABLE_NAME + " ON " + ThreadTable.TABLE_NAME + "." + ThreadTable.RECIPIENT_ID + " = " + TABLE_NAME + "." + RECIPIENT_ID + - " WHERE " + ACTIVE + " = 1 " + - " AND (" + - SHOW_AS_STORY_STATE + " = " + ShowAsStoryState.ALWAYS.code + - " OR (" + SHOW_AS_STORY_STATE + " = " + ShowAsStoryState.IF_ACTIVE.code + " AND active_timestamp IS NOT NULL)" + - ") ORDER BY active_timestamp DESC"; - - try (Cursor cursor = getReadableDatabase().query(query)) { - if (cursor == null || cursor.getCount() == 0) { - return Collections.emptyList(); - } - - List results = new ArrayList<>(cursor.getCount()); - while (cursor.moveToNext()) { - results.add(GroupId.parse(CursorUtil.requireString(cursor, GROUP_ID))); - } - - return results; - } - } - - public @NonNull ShowAsStoryState getShowAsStoryState(@NonNull GroupId groupId) { - String[] projection = SqlUtil.buildArgs(SHOW_AS_STORY_STATE); - String where = GROUP_ID + " = ?"; - String[] whereArgs = SqlUtil.buildArgs(groupId.toString()); - - try (Cursor cursor = getReadableDatabase().query(TABLE_NAME, projection, where, whereArgs, null, null, null)) { - if (!cursor.moveToFirst()) { - throw new AssertionError("Group does not exist."); - } - - int serializedState = CursorUtil.requireInt(cursor, SHOW_AS_STORY_STATE); - return ShowAsStoryState.deserialize(serializedState); - } - } - - public void setShowAsStoryState(@NonNull GroupId groupId, @NonNull ShowAsStoryState showAsStoryState) { - ContentValues contentValues = new ContentValues(1); - contentValues.put(SHOW_AS_STORY_STATE, showAsStoryState.code); - - getWritableDatabase().update(TABLE_NAME, contentValues, GROUP_ID + " = ?", SqlUtil.buildArgs(groupId.toString())); - } - - public void setShowAsStoryState(@NonNull Collection recipientIds, @NonNull ShowAsStoryState showAsStoryState) { - ContentValues contentValues = new ContentValues(1); - List queries = SqlUtil.buildCollectionQuery(RECIPIENT_ID, recipientIds); - - contentValues.put(SHOW_AS_STORY_STATE, showAsStoryState.code); - - SQLiteDatabaseExtensionsKt.withinTransaction(getWritableDatabase(), db -> { - for (SqlUtil.Query query : queries) { - db.update(TABLE_NAME, contentValues, query.getWhere(), query.getWhereArgs()); - } - - return null; - }); - } - - public enum MemberSet { - FULL_MEMBERS_INCLUDING_SELF(true, false), - FULL_MEMBERS_EXCLUDING_SELF(false, false), - FULL_MEMBERS_AND_PENDING_INCLUDING_SELF(true, true), - FULL_MEMBERS_AND_PENDING_EXCLUDING_SELF(false, true); - - private final boolean includeSelf; - private final boolean includePending; - - MemberSet(boolean includeSelf, boolean includePending) { - this.includeSelf = includeSelf; - this.includePending = includePending; - } - } - - /** - * State object describing whether or not to display a story in a list. - */ - public enum ShowAsStoryState { - /** - * The default value. Display the group as a story if the group has stories in it currently. - */ - IF_ACTIVE(0), - /** - * Always display the group as a story unless explicitly removed. This state is entered if the - * user sends a story to a group or otherwise explicitly selects it to appear. - */ - ALWAYS(1), - /** - * Never display the story as a group. This state is entered if the user removes the group from - * their list, and is only navigated away from if the user explicitly adds the group again. - */ - NEVER(2); - - private final int code; - - ShowAsStoryState(int code) { - this.code = code; - } - - private static @NonNull ShowAsStoryState deserialize(int code) { - switch (code) { - case 0: - return IF_ACTIVE; - case 1: - return ALWAYS; - case 2: - return NEVER; - default: - throw new IllegalArgumentException("Unknown code: " + code); - } - } - } - - public enum MemberLevel { - NOT_A_MEMBER(false), - PENDING_MEMBER(false), - REQUESTING_MEMBER(false), - FULL_MEMBER(true), - ADMINISTRATOR(true); - - private final boolean inGroup; - - MemberLevel(boolean inGroup){ - this.inGroup = inGroup; - } - - public boolean isInGroup() { - return inGroup; - } - } - - public static class GroupQuery { - private final String searchQuery; - private final boolean includeInactive; - private final boolean includeV1; - private final boolean includeMms; - private final ContactSearchSortOrder sortOrder; - - private GroupQuery(@NonNull Builder builder) { - this.searchQuery = builder.searchQuery; - this.includeInactive = builder.includeInactive; - this.includeV1 = builder.includeV1; - this.includeMms = builder.includeMms; - this.sortOrder = builder.sortOrder; - } - - public static class Builder { - private String searchQuery = ""; - private boolean includeInactive = false; - private boolean includeV1 = false; - private boolean includeMms = false; - private ContactSearchSortOrder sortOrder = ContactSearchSortOrder.NATURAL; - - public @NonNull Builder withSearchQuery(@Nullable String query) { - this.searchQuery = TextUtils.isEmpty(query) ? "" : query; - return this; - } - - public @NonNull Builder withInactiveGroups(boolean includeInactive) { - this.includeInactive = includeInactive; - return this; - } - - public @NonNull Builder withV1Groups(boolean includeV1Groups) { - this.includeV1 = includeV1Groups; - return this; - } - - public @NonNull Builder withMmsGroups(boolean includeMmsGroups) { - this.includeMms = includeMmsGroups; - return this; - } - - public @NonNull Builder withSortOrder(@NonNull ContactSearchSortOrder sortOrder) { - this.sortOrder = sortOrder; - return this; - } - - public GroupQuery build() { - return new GroupQuery(this); - } - } - } - - public static class LegacyGroupInsertException extends IllegalStateException { - public LegacyGroupInsertException(@Nullable GroupId id) { - super("Tried to create a new GV1 entry when we already had a migrated GV2! " + id); - } - } - - public static class MissedGroupMigrationInsertException extends IllegalStateException { - public MissedGroupMigrationInsertException(@Nullable GroupId id) { - super("Tried to create a new GV2 entry when we already had a V1 group that mapped to the new ID! " + id); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.kt new file mode 100644 index 0000000000..3695b70c78 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.kt @@ -0,0 +1,1395 @@ +package org.thoughtcrime.securesms.database + +import android.content.ContentValues +import android.content.Context +import android.database.Cursor +import android.text.TextUtils +import androidx.annotation.WorkerThread +import androidx.core.content.contentValuesOf +import org.intellij.lang.annotations.Language +import org.signal.core.util.SetUtil +import org.signal.core.util.SqlUtil +import org.signal.core.util.SqlUtil.appendArg +import org.signal.core.util.SqlUtil.buildArgs +import org.signal.core.util.SqlUtil.buildCaseInsensitiveGlobPattern +import org.signal.core.util.SqlUtil.buildCollectionQuery +import org.signal.core.util.exists +import org.signal.core.util.isAbsent +import org.signal.core.util.logging.Log +import org.signal.core.util.optionalString +import org.signal.core.util.readToList +import org.signal.core.util.readToSingleInt +import org.signal.core.util.readToSingleObject +import org.signal.core.util.requireBlob +import org.signal.core.util.requireBoolean +import org.signal.core.util.requireInt +import org.signal.core.util.requireLong +import org.signal.core.util.requireNonNullString +import org.signal.core.util.requireString +import org.signal.core.util.select +import org.signal.core.util.toSingleLine +import org.signal.core.util.update +import org.signal.core.util.withinTransaction +import org.signal.libsignal.zkgroup.groups.GroupMasterKey +import org.signal.storageservice.protos.groups.Member +import org.signal.storageservice.protos.groups.local.DecryptedGroup +import org.signal.storageservice.protos.groups.local.DecryptedRequestingMember +import org.thoughtcrime.securesms.contacts.paged.ContactSearchSortOrder +import org.thoughtcrime.securesms.contacts.paged.collections.ContactSearchIterator +import org.thoughtcrime.securesms.crypto.SenderKeyUtil +import org.thoughtcrime.securesms.database.SignalDatabase.Companion.messages +import org.thoughtcrime.securesms.database.SignalDatabase.Companion.recipients +import org.thoughtcrime.securesms.database.model.GroupRecord +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.groups.BadGroupIdException +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.groups.GroupId.Push +import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange +import org.thoughtcrime.securesms.groups.v2.processing.GroupsV2StateProcessor +import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.util.Util +import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil +import org.whispersystems.signalservice.api.groupsv2.GroupChangeReconstruct +import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer +import org.whispersystems.signalservice.api.push.DistributionId +import org.whispersystems.signalservice.api.push.ServiceId +import org.whispersystems.signalservice.api.util.UuidUtil +import java.io.Closeable +import java.lang.AssertionError +import java.lang.IllegalArgumentException +import java.lang.IllegalStateException +import java.security.SecureRandom +import java.util.ArrayList +import java.util.Optional +import java.util.UUID +import java.util.stream.Collectors + +class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseTable(context, databaseHelper), RecipientIdDatabaseReference { + + companion object { + private val TAG = Log.tag(GroupTable::class.java) + + const val TABLE_NAME = "groups" + const val ID = "_id" + const val GROUP_ID = "group_id" + const val RECIPIENT_ID = "recipient_id" + const val TITLE = "title" + const val MEMBERS = "members" + const val AVATAR_ID = "avatar_id" + const val AVATAR_KEY = "avatar_key" + const val AVATAR_CONTENT_TYPE = "avatar_content_type" + const val AVATAR_RELAY = "avatar_relay" + const val AVATAR_DIGEST = "avatar_digest" + const val TIMESTAMP = "timestamp" + const val ACTIVE = "active" + const val MMS = "mms" + const val EXPECTED_V2_ID = "expected_v2_id" + const val UNMIGRATED_V1_MEMBERS = "former_v1_members" + const val DISTRIBUTION_ID = "distribution_id" + const val SHOW_AS_STORY_STATE = "display_as_story" + const val LAST_FORCE_UPDATE_TIMESTAMP = "last_force_update_timestamp" + + /** 32 bytes serialized [GroupMasterKey] */ + const val V2_MASTER_KEY = "master_key" + + /** Increments with every change to the group */ + const val V2_REVISION = "revision" + + /** Serialized [DecryptedGroup] protobuf */ + const val V2_DECRYPTED_GROUP = "decrypted_group" + + /** Was temporarily used for PNP accept by pni but is no longer needed/updated */ + @Deprecated("") + private val AUTH_SERVICE_ID = "auth_service_id" + + @JvmField + val CREATE_TABLE = """ + CREATE TABLE $TABLE_NAME ( + $ID INTEGER PRIMARY KEY, + $GROUP_ID TEXT, + $RECIPIENT_ID INTEGER, + $TITLE TEXT, + $MEMBERS TEXT, + $AVATAR_ID INTEGER, + $AVATAR_KEY BLOB, + $AVATAR_CONTENT_TYPE TEXT, + $AVATAR_RELAY TEXT, + $TIMESTAMP INTEGER, + $ACTIVE INTEGER DEFAULT 1, + $AVATAR_DIGEST BLOB, + $MMS INTEGER DEFAULT 0, + $V2_MASTER_KEY BLOB, + $V2_REVISION BLOB, + $V2_DECRYPTED_GROUP BLOB, + $EXPECTED_V2_ID TEXT DEFAULT NULL, + $UNMIGRATED_V1_MEMBERS TEXT DEFAULT NULL, + $DISTRIBUTION_ID TEXT DEFAULT NULL, + $SHOW_AS_STORY_STATE INTEGER DEFAULT 0, + $AUTH_SERVICE_ID TEXT DEFAULT NULL, + $LAST_FORCE_UPDATE_TIMESTAMP INTEGER DEFAULT 0 + ) + """ + + @JvmField + val CREATE_INDEXS = arrayOf( + "CREATE UNIQUE INDEX IF NOT EXISTS group_id_index ON $TABLE_NAME ($GROUP_ID);", + "CREATE UNIQUE INDEX IF NOT EXISTS group_recipient_id_index ON $TABLE_NAME ($RECIPIENT_ID);", + "CREATE UNIQUE INDEX IF NOT EXISTS expected_v2_id_index ON $TABLE_NAME ($EXPECTED_V2_ID);", + "CREATE UNIQUE INDEX IF NOT EXISTS group_distribution_id_index ON $TABLE_NAME($DISTRIBUTION_ID);" + ) + + private val GROUP_PROJECTION = arrayOf( + GROUP_ID, + RECIPIENT_ID, + TITLE, + MEMBERS, + UNMIGRATED_V1_MEMBERS, + AVATAR_ID, + AVATAR_KEY, + AVATAR_CONTENT_TYPE, + AVATAR_RELAY, + AVATAR_DIGEST, + TIMESTAMP, + ACTIVE, + MMS, + V2_MASTER_KEY, + V2_REVISION, + V2_DECRYPTED_GROUP, + LAST_FORCE_UPDATE_TIMESTAMP + ) + + val TYPED_GROUP_PROJECTION = GROUP_PROJECTION + .filterNot { it == RECIPIENT_ID } + .map { columnName: String -> "$TABLE_NAME.$columnName" } + .toList() + } + + fun getGroup(recipientId: RecipientId): Optional { + readableDatabase + .select() + .from(TABLE_NAME) + .where("$RECIPIENT_ID = ?", recipientId) + .run() + .use { cursor -> + return if (cursor.moveToFirst()) { + getGroup(cursor) + } else { + Optional.empty() + } + } + } + + fun getGroup(groupId: GroupId): Optional { + readableDatabase + .select() + .from(TABLE_NAME) + .where("$GROUP_ID = ?", groupId.toString()) + .run() + .use { cursor -> + return if (cursor.moveToFirst()) { + val groupRecord = getGroup(cursor) + if (groupRecord.isPresent && RemappedRecords.getInstance().areAnyRemapped(groupRecord.get().members)) { + val remaps = RemappedRecords.getInstance().buildRemapDescription(groupRecord.get().members) + Log.w(TAG, "Found a group with remapped recipients in it's membership list! Updating the list. GroupId: $groupId, Remaps: $remaps", true) + + val remapped: Collection = RemappedRecords.getInstance().remap(groupRecord.get().members) + + val updateCount = writableDatabase + .update(TABLE_NAME) + .values(MEMBERS to remapped.serialize()) + .where("$GROUP_ID = ?", groupId) + .run() + + if (updateCount > 0) { + getGroup(groupId) + } else { + throw IllegalStateException("Failed to update group with remapped recipients!") + } + } else { + getGroup(cursor) + } + } else { + Optional.empty() + } + } + } + + /** + * Call if you are sure this group should exist. + * Finds group and throws if it cannot. + */ + fun requireGroup(groupId: GroupId): GroupRecord { + val group = getGroup(groupId) + if (!group.isPresent) { + throw AssertionError("Group not found") + } + return group.get() + } + + fun groupExists(groupId: GroupId): Boolean { + return readableDatabase + .exists(TABLE_NAME) + .where("$GROUP_ID = ?", groupId.toString()) + .run() + } + + /** + * @return A gv1 group whose expected v2 ID matches the one provided. + */ + fun getGroupV1ByExpectedV2(gv2Id: GroupId.V2): Optional { + readableDatabase + .select(*GROUP_PROJECTION) + .from(TABLE_NAME) + .where("$EXPECTED_V2_ID = ?", gv2Id) + .run() + .use { cursor -> + return if (cursor.moveToFirst()) { + getGroup(cursor) + } else { + Optional.empty() + } + } + } + + fun getGroupByDistributionId(distributionId: DistributionId): Optional { + readableDatabase + .select() + .from(TABLE_NAME) + .where("$DISTRIBUTION_ID = ?", distributionId) + .run() + .use { cursor -> + return if (cursor.moveToFirst()) { + getGroup(cursor) + } else { + Optional.empty() + } + } + } + + fun removeUnmigratedV1Members(id: GroupId.V2) { + val group = getGroup(id) + if (!group.isPresent) { + Log.w(TAG, "Couldn't find the group!", Throwable()) + return + } + + removeUnmigratedV1Members(id, group.get().unmigratedV1Members) + } + + /** + * Removes the specified members from the list of 'unmigrated V1 members' -- the list of members + * that were either dropped or had to be invited when migrating the group from V1->V2. + */ + fun removeUnmigratedV1Members(id: GroupId.V2, toRemove: List) { + val group = getGroup(id) + if (group.isAbsent()) { + Log.w(TAG, "Couldn't find the group!", Throwable()) + return + } + + val newUnmigrated = group.get().unmigratedV1Members - toRemove.toSet() + + writableDatabase + .update(TABLE_NAME) + .values(UNMIGRATED_V1_MEMBERS to if (newUnmigrated.isEmpty()) null else newUnmigrated.serialize()) + .where("$GROUP_ID = ?", id) + .run() + + Recipient.live(Recipient.externalGroupExact(id).id).refresh() + } + + private fun getGroup(cursor: Cursor?): Optional { + val reader = Reader(cursor) + return Optional.ofNullable(reader.getCurrent()) + } + + /** + * @return local db group revision or -1 if not present. + */ + fun getGroupV2Revision(groupId: GroupId.V2): Int { + readableDatabase + .select() + .from(TABLE_NAME) + .where("$GROUP_ID = ?", groupId.toString()) + .run() + .use { cursor -> + return if (cursor.moveToNext()) { + cursor.getInt(cursor.getColumnIndexOrThrow(V2_REVISION)) + } else { + -1 + } + } + } + + fun isUnknownGroup(groupId: GroupId): Boolean { + val group = getGroup(groupId) + if (!group.isPresent) { + return true + } + + val noMetadata = !group.get().hasAvatar() && group.get().title.isNullOrEmpty() + val noMembers = group.get().members.isEmpty() || group.get().members.size == 1 && group.get().members.contains(Recipient.self().id) + + return noMetadata && noMembers + } + + fun queryGroupsByTitle(inputQuery: String, includeInactive: Boolean, excludeV1: Boolean, excludeMms: Boolean): Reader { + val query = getGroupQueryWhereStatement(inputQuery, includeInactive, excludeV1, excludeMms) + val cursor = databaseHelper.signalReadableDatabase.query(TABLE_NAME, null, query.where, query.whereArgs, null, null, "$TITLE COLLATE NOCASE ASC") + return Reader(cursor) + } + + fun queryGroupsByMembership(recipientIds: Set, includeInactive: Boolean, excludeV1: Boolean, excludeMms: Boolean): Reader { + var recipientIds = recipientIds + if (recipientIds.isEmpty()) { + return Reader(null) + } + + if (recipientIds.size > 30) { + Log.w(TAG, "[queryGroupsByMembership] Large set of recipientIds (${recipientIds.size})! Using the first 30.") + recipientIds = recipientIds.take(30).toSet() + } + + val recipientLikeClauses = recipientIds + .map { it.toLong() } + .map { id -> "($MEMBERS LIKE $id || ',%' OR $MEMBERS LIKE '%,' || $id || ',%' OR $MEMBERS LIKE '%,' || $id)" } + .toList() + + var query: String + val queryArgs: Array + val membershipQuery = "(" + Util.join(recipientLikeClauses, " OR ") + ")" + + if (includeInactive) { + query = "$membershipQuery AND ($ACTIVE = ? OR $RECIPIENT_ID IN (SELECT ${ThreadTable.RECIPIENT_ID} FROM ${ThreadTable.TABLE_NAME}))" + queryArgs = buildArgs(1) + } else { + query = "$membershipQuery AND $ACTIVE = ?" + queryArgs = buildArgs(1) + } + + if (excludeV1) { + query += " AND $EXPECTED_V2_ID IS NULL" + } + + if (excludeMms) { + query += " AND $MMS = 0" + } + + return Reader(readableDatabase.query(TABLE_NAME, null, query, queryArgs, null, null, null)) + } + + private fun queryGroupsByRecency(groupQuery: GroupQuery): Reader { + val query = getGroupQueryWhereStatement(groupQuery.searchQuery, groupQuery.includeInactive, !groupQuery.includeV1, !groupQuery.includeMms) + val sql = """ + SELECT * + FROM $TABLE_NAME LEFT JOIN ${ThreadTable.TABLE_NAME} ON $TABLE_NAME.$RECIPIENT_ID = ${ThreadTable.TABLE_NAME}.${ThreadTable.RECIPIENT_ID} + WHERE ${query.where} + ORDER BY ${ThreadTable.TABLE_NAME}.${ThreadTable.DATE} DESC + """.toSingleLine() + + return Reader(databaseHelper.signalReadableDatabase.rawQuery(sql, query.whereArgs)) + } + + fun queryGroups(groupQuery: GroupQuery): Reader { + return if (groupQuery.sortOrder === ContactSearchSortOrder.NATURAL) { + queryGroupsByTitle(groupQuery.searchQuery, groupQuery.includeInactive, !groupQuery.includeV1, !groupQuery.includeMms) + } else { + queryGroupsByRecency(groupQuery) + } + } + + private fun getGroupQueryWhereStatement(inputQuery: String, includeInactive: Boolean, excludeV1: Boolean, excludeMms: Boolean): SqlUtil.Query { + var query: String + val queryArgs: Array + val caseInsensitiveQuery = buildCaseInsensitiveGlobPattern(inputQuery) + + if (includeInactive) { + query = "$TITLE GLOB ? AND ($ACTIVE = ? OR $RECIPIENT_ID IN (SELECT ${ThreadTable.RECIPIENT_ID} FROM ${ThreadTable.TABLE_NAME}))" + queryArgs = buildArgs(caseInsensitiveQuery, 1) + } else { + query = "$TITLE GLOB ? AND $ACTIVE = ?" + queryArgs = buildArgs(caseInsensitiveQuery, 1) + } + + if (excludeV1) { + query += " AND $EXPECTED_V2_ID IS NULL" + } + + if (excludeMms) { + query += " AND $MMS = 0" + } + + return SqlUtil.Query(query, queryArgs) + } + + fun getOrCreateDistributionId(groupId: GroupId.V2): DistributionId { + readableDatabase + .select(DISTRIBUTION_ID) + .from(TABLE_NAME) + .where("$GROUP_ID = ?", groupId) + .run() + .use { cursor -> + return if (cursor.moveToFirst()) { + val serialized = cursor.optionalString(DISTRIBUTION_ID) + if (serialized.isPresent) { + DistributionId.from(serialized.get()) + } else { + Log.w(TAG, "Missing distributionId! Creating one.") + val distributionId = DistributionId.create() + + val count = writableDatabase + .update(TABLE_NAME) + .values(DISTRIBUTION_ID to distributionId.toString()) + .where("$GROUP_ID = ?", groupId) + .run() + + check(count >= 1) { "Tried to create a distributionId for $groupId, but it doesn't exist!" } + + distributionId + } + } else { + throw IllegalStateException("Group $groupId doesn't exist!") + } + } + } + + fun getOrCreateMmsGroupForMembers(members: List): GroupId.Mms { + val sortedMembers = members.sorted() + + readableDatabase + .select(GROUP_ID) + .from(TABLE_NAME) + .where("$MEMBERS = ? AND $MMS = ?", sortedMembers.serialize(), 1) + .run() + .use { cursor -> + return if (cursor.moveToNext()) { + GroupId.parseOrThrow(cursor.requireNonNullString(GROUP_ID)).requireMms() + } else { + val groupId = GroupId.createMms(SecureRandom()) + create(groupId, null, sortedMembers) + groupId + } + } + } + + @WorkerThread + fun getPushGroupNamesContainingMember(recipientId: RecipientId): List { + return getPushGroupsContainingMember(recipientId) + .map { groupRecord -> Recipient.resolved(groupRecord.recipientId).getDisplayName(context) } + .toList() + } + + @WorkerThread + fun getPushGroupsContainingMember(recipientId: RecipientId): List { + return getGroupsContainingMember(recipientId, true) + } + + fun getGroupsContainingMember(recipientId: RecipientId, pushOnly: Boolean): List { + return getGroupsContainingMember(recipientId, pushOnly, false) + } + + @WorkerThread + fun getGroupsContainingMember(recipientId: RecipientId, pushOnly: Boolean, includeInactive: Boolean): List { + val table = "$TABLE_NAME INNER JOIN ${ThreadTable.TABLE_NAME} ON $TABLE_NAME.$RECIPIENT_ID = ${ThreadTable.TABLE_NAME}.${ThreadTable.RECIPIENT_ID}" + var query = "$MEMBERS LIKE ?" + var args = buildArgs("%${recipientId.serialize()}%") + val orderBy = "${ThreadTable.TABLE_NAME}.${ThreadTable.DATE} DESC" + + if (pushOnly) { + query += " AND $MMS = ?" + args = appendArg(args, "0") + } + + if (!includeInactive) { + query += " AND $ACTIVE = ?" + args = appendArg(args, "1") + } + + return readableDatabase + .query(table, null, query, args, null, null, orderBy) + .readToList { cursor -> + val serializedMembers = cursor.requireNonNullString(MEMBERS) + if (RecipientId.serializedListContains(serializedMembers, recipientId)) { + getGroup(cursor).get() + } else { + null + } + } + .filterNotNull() + } + + fun getGroups(): Reader { + val cursor = readableDatabase + .select() + .from(TABLE_NAME) + .run() + return Reader(cursor) + } + + fun getActiveGroupCount(): Int { + return readableDatabase + .select("COUNT(*)") + .from(TABLE_NAME) + .where("$ACTIVE = ?", 1) + .run() + .readToSingleInt(0) + } + + @WorkerThread + fun getGroupMemberIds(groupId: GroupId, memberSet: MemberSet): List { + return if (groupId.isV2) { + getGroup(groupId) + .map { it.requireV2GroupProperties().getMemberRecipientIds(memberSet) } + .orElse(emptyList()) + } else { + val currentMembers: MutableList = getCurrentMembers(groupId) + if (!memberSet.includeSelf) { + currentMembers -= Recipient.self().id + } + currentMembers + } + } + + @WorkerThread + fun getGroupMembers(groupId: GroupId, memberSet: MemberSet): List { + return if (groupId.isV2) { + getGroup(groupId) + .map { it.requireV2GroupProperties().getMemberRecipients(memberSet) } + .orElse(emptyList()) + } else { + val currentMembers: List = getCurrentMembers(groupId) + val recipients: MutableList = ArrayList(currentMembers.size) + + for (member in currentMembers) { + val resolved = Recipient.resolved(member) + if (memberSet.includeSelf || !resolved.isSelf) { + recipients += resolved + } + } + + recipients + } + } + + fun create(groupId: GroupId.V1, title: String?, members: Collection, avatar: SignalServiceAttachmentPointer?, relay: String?) { + if (groupExists(groupId.deriveV2MigrationGroupId())) { + throw LegacyGroupInsertException(groupId) + } + + create(groupId, title, members, avatar, relay, null, null) + } + + fun create(groupId: GroupId.Mms, title: String?, members: Collection) { + create(groupId, if (title.isNullOrEmpty()) null else title, members, null, null, null, null) + } + + @JvmOverloads + fun create(groupMasterKey: GroupMasterKey, groupState: DecryptedGroup, force: Boolean = false): GroupId.V2 { + val groupId = GroupId.v2(groupMasterKey) + + if (!force && getGroupV1ByExpectedV2(groupId).isPresent) { + throw MissedGroupMigrationInsertException(groupId) + } else if (force) { + Log.w(TAG, "Forcing the creation of a group even though we already have a V1 ID!") + } + + create(groupId, groupState.title, emptyList(), null, null, groupMasterKey, groupState) + + return groupId + } + + /** + * There was a point in time where we weren't properly responding to group creates on linked devices. This would result in us having a Recipient entry for the + * group, but we'd either be missing the group entry, or that entry would be missing a master key. This method fixes this scenario. + */ + fun fixMissingMasterKey(authServiceId: ServiceId?, groupMasterKey: GroupMasterKey) { + val groupId = GroupId.v2(groupMasterKey) + if (getGroupV1ByExpectedV2(groupId).isPresent) { + Log.w(TAG, "There already exists a V1 group that should be migrated into this group. But if the recipient already exists, there's not much we can do here.") + } + + writableDatabase.withinTransaction { db -> + val updated = db + .update(TABLE_NAME) + .values(V2_MASTER_KEY to groupMasterKey.serialize()) + .where("$GROUP_ID = ?", groupId) + .run() + + if (updated < 1) { + Log.w(TAG, "No group entry. Creating restore placeholder for $groupId") + create( + groupMasterKey, + DecryptedGroup.newBuilder() + .setRevision(GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION) + .build(), + true + ) + } else { + Log.w(TAG, "Had a group entry, but it was missing a master key. Updated.") + } + } + + Log.w(TAG, "Scheduling request for latest group info for $groupId") + ApplicationDependencies.getJobManager().add(RequestGroupV2InfoJob(groupId)) + } + + /** + * @param groupMasterKey null for V1, must be non-null for V2 (presence dictates group version). + */ + private fun create( + groupId: GroupId, + title: String?, + memberCollection: Collection, + avatar: SignalServiceAttachmentPointer?, + relay: String?, + groupMasterKey: GroupMasterKey?, + groupState: DecryptedGroup? + ) { + val groupRecipientId = recipients.getOrInsertFromGroupId(groupId) + val members: List = memberCollection.toSet().sorted() + var groupMembers: List = members + + val values = ContentValues() + + values.put(RECIPIENT_ID, groupRecipientId.serialize()) + values.put(GROUP_ID, groupId.toString()) + values.put(TITLE, title) + values.put(MEMBERS, members.serialize()) + values.put(MMS, groupId.isMms) + + if (avatar != null) { + values.put(AVATAR_ID, avatar.remoteId.v2.get()) + values.put(AVATAR_KEY, avatar.key) + values.put(AVATAR_CONTENT_TYPE, avatar.contentType) + values.put(AVATAR_DIGEST, avatar.digest.orElse(null)) + } else { + values.put(AVATAR_ID, 0) + } + + values.put(AVATAR_RELAY, relay) + values.put(TIMESTAMP, System.currentTimeMillis()) + + if (groupId.isV2) { + values.put(ACTIVE, if (groupState != null && gv2GroupActive(groupState)) 1 else 0) + values.put(DISTRIBUTION_ID, DistributionId.create().toString()) + } else if (groupId.isV1) { + values.put(ACTIVE, 1) + values.put(EXPECTED_V2_ID, groupId.requireV1().deriveV2MigrationGroupId().toString()) + } else { + values.put(ACTIVE, 1) + } + + if (groupMasterKey != null) { + if (groupState == null) { + throw AssertionError("V2 master key but no group state") + } + + groupId.requireV2() + groupMembers = getV2GroupMembers(groupState, true) + + values.put(V2_MASTER_KEY, groupMasterKey.serialize()) + values.put(V2_REVISION, groupState.revision) + values.put(V2_DECRYPTED_GROUP, groupState.toByteArray()) + values.put(MEMBERS, groupMembers.serialize()) + } else { + if (groupId.isV2) { + throw AssertionError("V2 group id but no master key") + } + } + + writableDatabase.insert(TABLE_NAME, null, values) + + if (groupState != null && groupState.hasDisappearingMessagesTimer()) { + recipients.setExpireMessages(groupRecipientId, groupState.disappearingMessagesTimer.duration) + } + + if (groupId.isMms || Recipient.resolved(groupRecipientId).isProfileSharing) { + recipients.setHasGroupsInCommon(groupMembers) + } + + Recipient.live(groupRecipientId).refresh() + notifyConversationListListeners() + } + + fun update(groupId: GroupId.V1, title: String?, avatar: SignalServiceAttachmentPointer?) { + val contentValues = ContentValues().apply { + if (title != null) { + put(TITLE, title) + } + + if (avatar != null) { + put(AVATAR_ID, avatar.remoteId.v2.get()) + put(AVATAR_CONTENT_TYPE, avatar.contentType) + put(AVATAR_KEY, avatar.key) + put(AVATAR_DIGEST, avatar.digest.orElse(null)) + } else { + put(AVATAR_ID, 0) + } + } + + writableDatabase + .update(TABLE_NAME) + .values(contentValues) + .where("$GROUP_ID = ?", groupId) + .run() + + val groupRecipient = recipients.getOrInsertFromGroupId(groupId) + + Recipient.live(groupRecipient).refresh() + notifyConversationListListeners() + } + + /** + * Migrates a V1 group to a V2 group. + * + * @param decryptedGroup The state that represents the group on the server. This will be used to + * determine if we need to save our old membership list and stuff. + */ + fun migrateToV2( + threadId: Long, + groupIdV1: GroupId.V1, + decryptedGroup: DecryptedGroup + ): GroupId.V2 { + val groupIdV2 = groupIdV1.deriveV2MigrationGroupId() + val groupMasterKey = groupIdV1.deriveV2MigrationMasterKey() + + writableDatabase.withinTransaction { db -> + val record = getGroup(groupIdV1).get() + + val newMembers: MutableList = DecryptedGroupUtil.membersToUuidList(decryptedGroup.membersList).toRecipientIds() + val pendingMembers: List = DecryptedGroupUtil.pendingToUuidList(decryptedGroup.pendingMembersList).toRecipientIds() + newMembers.addAll(pendingMembers) + + val droppedMembers: List = SetUtil.difference(record.members, newMembers).toList() + val unmigratedMembers: List = pendingMembers + droppedMembers + + val updated: Int = db.update(TABLE_NAME) + .values( + GROUP_ID to groupIdV2.toString(), + V2_MASTER_KEY to groupMasterKey.serialize(), + DISTRIBUTION_ID to DistributionId.create().toString(), + EXPECTED_V2_ID to null, + UNMIGRATED_V1_MEMBERS to if (unmigratedMembers.isEmpty()) null else unmigratedMembers.serialize() + ) + .where("$GROUP_ID = ?", groupIdV1) + .run() + + if (updated != 1) { + throw AssertionError() + } + + recipients.updateGroupId(groupIdV1, groupIdV2) + update(groupMasterKey, decryptedGroup) + messages.insertGroupV1MigrationEvents( + record.recipientId, + threadId, + GroupMigrationMembershipChange(pendingMembers, droppedMembers) + ) + } + + return groupIdV2 + } + + fun update(groupMasterKey: GroupMasterKey, decryptedGroup: DecryptedGroup) { + update(GroupId.v2(groupMasterKey), decryptedGroup) + } + + fun update(groupId: GroupId.V2, decryptedGroup: DecryptedGroup) { + val groupRecipientId: RecipientId = recipients.getOrInsertFromGroupId(groupId) + val existingGroup: Optional = getGroup(groupId) + val title: String = decryptedGroup.title + + val contentValues = ContentValues() + contentValues.put(TITLE, title) + contentValues.put(V2_REVISION, decryptedGroup.revision) + contentValues.put(V2_DECRYPTED_GROUP, decryptedGroup.toByteArray()) + contentValues.put(ACTIVE, if (gv2GroupActive(decryptedGroup)) 1 else 0) + + if (existingGroup.isPresent && existingGroup.get().unmigratedV1Members.isNotEmpty() && existingGroup.get().isV2Group) { + val unmigratedV1Members: MutableSet = existingGroup.get().unmigratedV1Members.toMutableSet() + + val change = GroupChangeReconstruct.reconstructGroupChange(existingGroup.get().requireV2GroupProperties().decryptedGroup, decryptedGroup) + + val addedMembers: Set = DecryptedGroupUtil.membersToUuidList(change.newMembersList).toRecipientIds().toSet() + val removedMembers: Set = DecryptedGroupUtil.removedMembersUuidList(change).toRecipientIds().toSet() + val addedInvites: Set = DecryptedGroupUtil.pendingToUuidList(change.newPendingMembersList).toRecipientIds().toSet() + val removedInvites: Set = DecryptedGroupUtil.removedPendingMembersUuidList(change).toRecipientIds().toSet() + val acceptedInvites: Set = DecryptedGroupUtil.membersToUuidList(change.promotePendingMembersList).toRecipientIds().toSet() + + unmigratedV1Members -= addedMembers + unmigratedV1Members -= removedMembers + unmigratedV1Members -= addedInvites + unmigratedV1Members -= removedInvites + unmigratedV1Members -= acceptedInvites + + contentValues.put(UNMIGRATED_V1_MEMBERS, if (unmigratedV1Members.isEmpty()) null else unmigratedV1Members.serialize()) + } + + val groupMembers = getV2GroupMembers(decryptedGroup, true) + contentValues.put(MEMBERS, groupMembers.serialize()) + + if (existingGroup.isPresent && existingGroup.get().isV2Group) { + val change = GroupChangeReconstruct.reconstructGroupChange(existingGroup.get().requireV2GroupProperties().decryptedGroup, decryptedGroup) + val removed: List = DecryptedGroupUtil.removedMembersUuidList(change) + + if (removed.isNotEmpty()) { + val distributionId = existingGroup.get().distributionId!! + Log.i(TAG, removed.size.toString() + " members were removed from group " + groupId + ". Rotating the DistributionId " + distributionId) + SenderKeyUtil.rotateOurKey(distributionId) + } + } + + writableDatabase + .update(TABLE_NAME) + .values(contentValues) + .where("$GROUP_ID = ?", groupId.toString()) + .run() + + if (decryptedGroup.hasDisappearingMessagesTimer()) { + recipients.setExpireMessages(groupRecipientId, decryptedGroup.disappearingMessagesTimer.duration) + } + + if (groupId.isMms || Recipient.resolved(groupRecipientId).isProfileSharing) { + recipients.setHasGroupsInCommon(groupMembers) + } + + Recipient.live(groupRecipientId).refresh() + notifyConversationListListeners() + } + + fun updateTitle(groupId: GroupId.V1, title: String?) { + updateTitle(groupId as GroupId, title) + } + + fun updateTitle(groupId: GroupId.Mms, title: String?) { + updateTitle(groupId as GroupId, if (title.isNullOrEmpty()) null else title) + } + + private fun updateTitle(groupId: GroupId, title: String?) { + if (!groupId.isV1 && !groupId.isMms) { + throw AssertionError() + } + + writableDatabase + .update(TABLE_NAME) + .values(TITLE to title) + .where("$GROUP_ID = ?", groupId) + .run() + + val groupRecipient = recipients.getOrInsertFromGroupId(groupId) + Recipient.live(groupRecipient).refresh() + } + + /** + * Used to bust the Glide cache when an avatar changes. + */ + fun onAvatarUpdated(groupId: GroupId, hasAvatar: Boolean) { + writableDatabase + .update(TABLE_NAME) + .values(AVATAR_ID to if (hasAvatar) Math.abs(SecureRandom().nextLong()) else 0) + .where("$GROUP_ID = ?", groupId) + .run() + + val groupRecipient = recipients.getOrInsertFromGroupId(groupId) + Recipient.live(groupRecipient).refresh() + } + + fun updateMembers(groupId: GroupId, members: List) { + writableDatabase + .update(TABLE_NAME) + .values( + MEMBERS to members.sorted().serialize(), + ACTIVE to 1 + ) + .where("$GROUP_ID = ?", groupId) + .run() + + val groupRecipient = recipients.getOrInsertFromGroupId(groupId) + Recipient.live(groupRecipient).refresh() + } + + fun remove(groupId: GroupId, source: RecipientId) { + val currentMembers: MutableList = getCurrentMembers(groupId) + currentMembers -= source + + writableDatabase + .update(TABLE_NAME) + .values(MEMBERS to currentMembers.serialize()) + .where("$GROUP_ID = ?", groupId) + .run() + + val groupRecipient = recipients.getOrInsertFromGroupId(groupId) + Recipient.live(groupRecipient).refresh() + } + + private fun getCurrentMembers(groupId: GroupId): MutableList { + return readableDatabase + .select(MEMBERS) + .from(TABLE_NAME) + .where("$GROUP_ID = ?", groupId) + .run() + .readToList { cursor -> + val serializedMembers = cursor.requireNonNullString(MEMBERS) + return RecipientId.fromSerializedList(serializedMembers) + } + .toMutableList() + } + + fun isActive(groupId: GroupId): Boolean { + val record = getGroup(groupId) + return record.isPresent && record.get().isActive + } + + fun setActive(groupId: GroupId, active: Boolean) { + writableDatabase + .update(TABLE_NAME) + .values(ACTIVE to if (active) 1 else 0) + .where("$GROUP_ID = ?", groupId) + .run() + } + + fun setLastForceUpdateTimestamp(groupId: GroupId, timestamp: Long) { + writableDatabase + .update(TABLE_NAME) + .values(LAST_FORCE_UPDATE_TIMESTAMP to timestamp) + .where("$GROUP_ID = ?", groupId) + .run() + } + + @WorkerThread + fun isCurrentMember(groupId: Push, recipientId: RecipientId): Boolean { + readableDatabase + .select(MEMBERS) + .from(TABLE_NAME) + .where("$GROUP_ID = ?", groupId) + .run() + .use { cursor -> + return if (cursor.moveToFirst()) { + val serializedMembers = cursor.requireNonNullString(MEMBERS) + RecipientId.serializedListContains(serializedMembers, recipientId) + } else { + false + } + } + } + + fun getAllGroupV2Ids(): List { + return readableDatabase + .select(GROUP_ID) + .from(TABLE_NAME) + .run() + .readToList { GroupId.parseOrThrow(it.requireNonNullString(GROUP_ID)) } + .filter { it.isV2 } + .map { it.requireV2() } + } + + /** + * Key: The 'expected' V2 ID (i.e. what a V1 ID would map to when migrated) + * Value: The matching V1 ID + */ + fun getAllExpectedV2Ids(): Map { + return readableDatabase + .select(GROUP_ID, EXPECTED_V2_ID) + .from(TABLE_NAME) + .where("$EXPECTED_V2_ID NOT NULL") + .run() + .readToList { cursor -> + val groupId = GroupId.parseOrThrow(cursor.requireNonNullString(GROUP_ID)).requireV1() + val expectedId = GroupId.parseOrThrow(cursor.requireNonNullString(EXPECTED_V2_ID)).requireV2() + expectedId to groupId + } + .toMap() + } + + override fun remapRecipient(fromId: RecipientId, toId: RecipientId) { + for (group in getGroupsContainingMember(fromId, false, true)) { + val newMembers: Set = group.members.toSet() - fromId + toId + + writableDatabase + .update(TABLE_NAME) + .values(MEMBERS to newMembers.serialize()) + .where("$RECIPIENT_ID = ?", group.recipientId) + .run() + + if (group.isV2Group) { + removeUnmigratedV1Members(group.id.requireV2(), listOf(fromId)) + } + } + } + + class Reader(val cursor: Cursor?) : Closeable, ContactSearchIterator { + + fun getNext(): GroupRecord? { + return if (cursor == null || !cursor.moveToNext()) { + null + } else { + getCurrent() + } + } + + override fun getCount(): Int { + return cursor?.count ?: 0 + } + + fun getCurrent(): GroupRecord? { + return if (cursor == null || cursor.requireString(GROUP_ID) == null || cursor.requireLong(RECIPIENT_ID) == 0L) { + null + } else { + GroupRecord( + id = GroupId.parseOrThrow(cursor.requireNonNullString(GROUP_ID)), + recipientId = RecipientId.from(cursor.requireNonNullString(RECIPIENT_ID)), + title = cursor.requireString(TITLE), + serializedMembers = cursor.requireString(MEMBERS), + serializedUnmigratedV1Members = cursor.requireString(UNMIGRATED_V1_MEMBERS), + avatarId = cursor.requireLong(AVATAR_ID), + avatarKey = cursor.requireBlob(AVATAR_KEY), + avatarContentType = cursor.requireString(AVATAR_CONTENT_TYPE), + relay = cursor.requireString(AVATAR_RELAY), + isActive = cursor.requireBoolean(ACTIVE), + avatarDigest = cursor.requireBlob(AVATAR_DIGEST), + isMms = cursor.requireBoolean(MMS), + groupMasterKeyBytes = cursor.requireBlob(V2_MASTER_KEY), + groupRevision = cursor.requireInt(V2_REVISION), + decryptedGroupBytes = cursor.requireBlob(V2_DECRYPTED_GROUP), + distributionId = cursor.optionalString(DISTRIBUTION_ID).map { id -> DistributionId.from(id) }.orElse(null), + lastForceUpdateTimestamp = cursor.requireLong(LAST_FORCE_UPDATE_TIMESTAMP) + ) + } + } + + override fun close() { + cursor?.close() + } + + override fun moveToPosition(n: Int) { + cursor!!.moveToPosition(n) + } + + override fun hasNext(): Boolean { + return cursor != null && !cursor.isLast && !cursor.isAfterLast + } + + override fun next(): GroupRecord { + return getNext()!! + } + } + + class V2GroupProperties(val groupMasterKey: GroupMasterKey, val groupRevision: Int, val decryptedGroupBytes: ByteArray) { + val decryptedGroup: DecryptedGroup by lazy { + DecryptedGroup.parseFrom(decryptedGroupBytes) + } + + val bannedMembers: Set by lazy { + DecryptedGroupUtil.bannedMembersToUuidSet(decryptedGroup.bannedMembersList) + } + + fun isAdmin(recipient: Recipient): Boolean { + val serviceId = recipient.serviceId + + return if (serviceId.isPresent) { + DecryptedGroupUtil.findMemberByUuid(decryptedGroup.membersList, serviceId.get().uuid()) + .map { it.role == Member.Role.ADMINISTRATOR } + .orElse(false) + } else { + false + } + } + + fun getAdmins(members: List): List { + return members.stream().filter { recipient: Recipient -> isAdmin(recipient) }.collect(Collectors.toList()) + } + + fun memberLevel(serviceId: Optional): MemberLevel { + if (!serviceId.isPresent) { + return MemberLevel.NOT_A_MEMBER + } + + var memberLevel: Optional = DecryptedGroupUtil.findMemberByUuid(decryptedGroup.membersList, serviceId.get().uuid()) + .map { member -> + if (member.role == Member.Role.ADMINISTRATOR) { + MemberLevel.ADMINISTRATOR + } else { + MemberLevel.FULL_MEMBER + } + } + + if (memberLevel.isAbsent()) { + memberLevel = DecryptedGroupUtil.findPendingByUuid(decryptedGroup.pendingMembersList, serviceId.get().uuid()) + .map { MemberLevel.PENDING_MEMBER } + } + + if (memberLevel.isAbsent()) { + memberLevel = DecryptedGroupUtil.findRequestingByUuid(decryptedGroup.requestingMembersList, serviceId.get().uuid()) + .map { m: DecryptedRequestingMember? -> MemberLevel.REQUESTING_MEMBER } + } + + return if (memberLevel.isPresent) { + memberLevel.get() + } else { + MemberLevel.NOT_A_MEMBER + } + } + + fun getMemberRecipients(memberSet: MemberSet): List { + return Recipient.resolvedList(getMemberRecipientIds(memberSet)) + } + + fun getMemberRecipientIds(memberSet: MemberSet): List { + val includeSelf = memberSet.includeSelf + val selfUuid = SignalStore.account().requireAci().uuid() + val recipients: MutableList = ArrayList(decryptedGroup.membersCount + decryptedGroup.pendingMembersCount) + + var unknownMembers = 0 + var unknownPending = 0 + + for (uuid in DecryptedGroupUtil.toUuidList(decryptedGroup.membersList)) { + if (UuidUtil.UNKNOWN_UUID == uuid) { + unknownMembers++ + } else if (includeSelf || selfUuid != uuid) { + recipients += RecipientId.from(ServiceId.from(uuid)) + } + } + + if (memberSet.includePending) { + for (uuid in DecryptedGroupUtil.pendingToUuidList(decryptedGroup.pendingMembersList)) { + if (UuidUtil.UNKNOWN_UUID == uuid) { + unknownPending++ + } else if (includeSelf || selfUuid != uuid) { + recipients += RecipientId.from(ServiceId.from(uuid)) + } + } + } + + if (unknownMembers + unknownPending > 0) { + Log.w(TAG, "Group contains $unknownPending unknown pending and $unknownMembers unknown full members") + } + + return recipients + } + } + + @Throws(BadGroupIdException::class) + fun getGroupsToDisplayAsStories(): List { + @Language("sql") + val query = """ + SELECT + $GROUP_ID, + ( + SELECT ${MessageTable.TABLE_NAME}.${MessageTable.DATE_RECEIVED} + FROM ${MessageTable.TABLE_NAME} + WHERE + ${MessageTable.TABLE_NAME}.${MessageTable.RECIPIENT_ID} = ${ThreadTable.TABLE_NAME}.${ThreadTable.RECIPIENT_ID} AND + ${MessageTable.STORY_TYPE} > 1 + ORDER BY ${MessageTable.TABLE_NAME}.${MessageTable.DATE_RECEIVED} DESC + LIMIT 1 + ) AS active_timestamp + FROM $TABLE_NAME INNER JOIN ${ThreadTable.TABLE_NAME} ON ${ThreadTable.TABLE_NAME}.${ThreadTable.RECIPIENT_ID} = $TABLE_NAME.$RECIPIENT_ID + WHERE + $ACTIVE = 1 AND + ( + $SHOW_AS_STORY_STATE = ${ShowAsStoryState.ALWAYS.code} OR + ( + $SHOW_AS_STORY_STATE = ${ShowAsStoryState.IF_ACTIVE.code} AND + active_timestamp IS NOT NULL + ) + ) + ORDER BY active_timestamp DESC + """.toSingleLine() + + return readableDatabase + .query(query) + .readToList { cursor -> + GroupId.parse(cursor.requireNonNullString(GROUP_ID)) + } + } + + fun getShowAsStoryState(groupId: GroupId): ShowAsStoryState { + return readableDatabase + .select(SHOW_AS_STORY_STATE) + .from(TABLE_NAME) + .where("$GROUP_ID = ?", groupId) + .run() + .readToSingleObject { cursor -> + val serializedState = cursor.requireInt(SHOW_AS_STORY_STATE) + ShowAsStoryState.deserialize(serializedState) + } ?: throw AssertionError("Group $groupId does not exist!") + } + + fun setShowAsStoryState(groupId: GroupId, showAsStoryState: ShowAsStoryState) { + writableDatabase + .update(TABLE_NAME) + .values(SHOW_AS_STORY_STATE to showAsStoryState.code) + .where("$GROUP_ID = ?", groupId) + .run() + } + + fun setShowAsStoryState(recipientIds: Collection, showAsStoryState: ShowAsStoryState) { + val queries = buildCollectionQuery(RECIPIENT_ID, recipientIds) + val contentValues = contentValuesOf(SHOW_AS_STORY_STATE to showAsStoryState.code) + + writableDatabase.withinTransaction { db -> + for (query in queries) { + db.update(TABLE_NAME, contentValues, query.where, query.whereArgs) + } + } + } + + private fun gv2GroupActive(decryptedGroup: DecryptedGroup): Boolean { + val aci = SignalStore.account().requireAci() + + return DecryptedGroupUtil.findMemberByUuid(decryptedGroup.membersList, aci.uuid()).isPresent || + DecryptedGroupUtil.findPendingByUuid(decryptedGroup.pendingMembersList, aci.uuid()).isPresent + } + + private fun List.toRecipientIds(): MutableList { + return uuidsToRecipientIds(this) + } + + private fun Collection.serialize(): String { + return RecipientId.toSerializedList(this) + } + + private fun uuidsToRecipientIds(uuids: List): MutableList { + return uuids + .asSequence() + .map { uuid -> + if (uuid == UuidUtil.UNKNOWN_UUID) { + Log.w(TAG, "Saw an unknown UUID when mapping to RecipientIds!") + null + } else { + val id = RecipientId.from(ServiceId.from(uuid)) + val remapped = RemappedRecords.getInstance().getRecipient(id) + if (remapped.isPresent) { + Log.w(TAG, "Saw that $id remapped to $remapped. Using the mapping.") + remapped.get() + } else { + id + } + } + } + .filterNotNull() + .sorted() + .toMutableList() + } + + private fun getV2GroupMembers(decryptedGroup: DecryptedGroup, shouldRetry: Boolean): List { + val uuids: List = DecryptedGroupUtil.membersToUuidList(decryptedGroup.membersList) + val ids: List = uuidsToRecipientIds(uuids) + + return if (RemappedRecords.getInstance().areAnyRemapped(ids)) { + if (shouldRetry) { + Log.w(TAG, "Found remapped records where we shouldn't. Clearing cache and trying again.") + RecipientId.clearCache() + RemappedRecords.getInstance().resetCache() + getV2GroupMembers(decryptedGroup, false) + } else { + throw IllegalStateException("Remapped records in group membership!") + } + } else { + ids + } + } + + enum class MemberSet(val includeSelf: Boolean, val includePending: Boolean) { + FULL_MEMBERS_INCLUDING_SELF(true, false), FULL_MEMBERS_EXCLUDING_SELF(false, false), FULL_MEMBERS_AND_PENDING_INCLUDING_SELF(true, true), FULL_MEMBERS_AND_PENDING_EXCLUDING_SELF(false, true); + } + + /** + * State object describing whether or not to display a story in a list. + */ + enum class ShowAsStoryState(val code: Int) { + /** + * The default value. Display the group as a story if the group has stories in it currently. + */ + IF_ACTIVE(0), + + /** + * Always display the group as a story unless explicitly removed. This state is entered if the + * user sends a story to a group or otherwise explicitly selects it to appear. + */ + ALWAYS(1), + + /** + * Never display the story as a group. This state is entered if the user removes the group from + * their list, and is only navigated away from if the user explicitly adds the group again. + */ + NEVER(2); + + companion object { + fun deserialize(code: Int): ShowAsStoryState { + return when (code) { + 0 -> IF_ACTIVE + 1 -> ALWAYS + 2 -> NEVER + else -> throw IllegalArgumentException("Unknown code: $code") + } + } + } + } + + enum class MemberLevel(val isInGroup: Boolean) { + NOT_A_MEMBER(false), + PENDING_MEMBER(false), + REQUESTING_MEMBER(false), + FULL_MEMBER(true), + ADMINISTRATOR(true) + } + + class GroupQuery private constructor(builder: Builder) { + val searchQuery: String + val includeInactive: Boolean + val includeV1: Boolean + val includeMms: Boolean + val sortOrder: ContactSearchSortOrder + + init { + searchQuery = builder.searchQuery + includeInactive = builder.includeInactive + includeV1 = builder.includeV1 + includeMms = builder.includeMms + sortOrder = builder.sortOrder + } + + class Builder { + var searchQuery = "" + var includeInactive = false + var includeV1 = false + var includeMms = false + var sortOrder = ContactSearchSortOrder.NATURAL + fun withSearchQuery(query: String?): Builder { + searchQuery = if (TextUtils.isEmpty(query)) "" else query!! + return this + } + + fun withInactiveGroups(includeInactive: Boolean): Builder { + this.includeInactive = includeInactive + return this + } + + fun withV1Groups(includeV1Groups: Boolean): Builder { + includeV1 = includeV1Groups + return this + } + + fun withMmsGroups(includeMmsGroups: Boolean): Builder { + includeMms = includeMmsGroups + return this + } + + fun withSortOrder(sortOrder: ContactSearchSortOrder): Builder { + this.sortOrder = sortOrder + return this + } + + fun build(): GroupQuery { + return GroupQuery(this) + } + } + } + + class LegacyGroupInsertException(id: GroupId?) : IllegalStateException("Tried to create a new GV1 entry when we already had a migrated GV2! $id") + class MissedGroupMigrationInsertException(id: GroupId?) : IllegalStateException("Tried to create a new GV2 entry when we already had a V1 group that mapped to the new ID! $id") +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.java b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.java index 55eb498ec6..fdcd9b9504 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.java @@ -54,6 +54,7 @@ import org.thoughtcrime.securesms.database.documents.NetworkFailure; import org.thoughtcrime.securesms.database.documents.NetworkFailureSet; import org.thoughtcrime.securesms.database.model.DisplayRecord; import org.thoughtcrime.securesms.database.model.GroupCallUpdateDetailsUtil; +import org.thoughtcrime.securesms.database.model.GroupRecord; import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord; import org.thoughtcrime.securesms.database.model.Mention; import org.thoughtcrime.securesms.database.model.MessageExportStatus; @@ -941,9 +942,9 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie } public void insertProfileNameChangeMessages(@NonNull Recipient recipient, @NonNull String newProfileName, @NonNull String previousProfileName) { - ThreadTable threadTable = SignalDatabase.threads(); - List groupRecords = SignalDatabase.groups().getGroupsContainingMember(recipient.getId(), false); - List threadIdsToUpdate = new LinkedList<>(); + ThreadTable threadTable = SignalDatabase.threads(); + List groupRecords = SignalDatabase.groups().getGroupsContainingMember(recipient.getId(), false); + List threadIdsToUpdate = new LinkedList<>(); byte[] profileChangeDetails = ProfileChangeDetails.newBuilder() .setProfileNameChange(ProfileChangeDetails.StringChange.newBuilder() @@ -959,7 +960,7 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie try { threadIdsToUpdate.add(threadTable.getThreadIdFor(recipient.getId())); - for (GroupTable.GroupRecord groupRecord : groupRecords) { + for (GroupRecord groupRecord : groupRecords) { if (groupRecord.isActive()) { threadIdsToUpdate.add(threadTable.getThreadIdFor(groupRecord.getRecipientId())); } @@ -1032,16 +1033,16 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie } public void insertNumberChangeMessages(@NonNull RecipientId recipientId) { - ThreadTable threadTable = SignalDatabase.threads(); - List groupRecords = SignalDatabase.groups().getGroupsContainingMember(recipientId, false); - List threadIdsToUpdate = new LinkedList<>(); + ThreadTable threadTable = SignalDatabase.threads(); + List groupRecords = SignalDatabase.groups().getGroupsContainingMember(recipientId, false); + List threadIdsToUpdate = new LinkedList<>(); SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); db.beginTransaction(); try { threadIdsToUpdate.add(threadTable.getThreadIdFor(recipientId)); - for (GroupTable.GroupRecord groupRecord : groupRecords) { + for (GroupRecord groupRecord : groupRecords) { if (groupRecord.isActive()) { threadIdsToUpdate.add(threadTable.getThreadIdFor(groupRecord.getRecipientId())); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt index 46c0140450..14a05d2097 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt @@ -1186,7 +1186,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da } } - for (id in groups.allGroupV2Ids) { + for (id in groups.getAllGroupV2Ids()) { val recipient = Recipient.externalGroupExact(id!!) val recipientId = recipient.id val existing: RecipientRecord = getRecordForSync(recipientId) ?: throw AssertionError() diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt index aab8fb5524..8b96db04fd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt @@ -1670,7 +1670,7 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa val recipientSettings = recipients.getRecord(context, cursor, RECIPIENT_ID) val recipient: Recipient = if (recipientSettings.groupId != null) { - GroupTable.Reader(cursor).current?.let { group -> + GroupTable.Reader(cursor).getCurrent()?.let { group -> val details = RecipientDetails( group.title, null, diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupRecord.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupRecord.kt new file mode 100644 index 0000000000..5efdd9dcc5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupRecord.kt @@ -0,0 +1,163 @@ +package org.thoughtcrime.securesms.database.model + +import androidx.annotation.WorkerThread +import org.signal.libsignal.zkgroup.groups.GroupMasterKey +import org.signal.storageservice.protos.groups.AccessControl +import org.signal.storageservice.protos.groups.local.EnabledState +import org.thoughtcrime.securesms.database.GroupTable +import org.thoughtcrime.securesms.groups.GroupAccessControl +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil +import org.whispersystems.signalservice.api.push.DistributionId +import java.lang.AssertionError +import java.util.Optional + +class GroupRecord( + val id: GroupId, + val recipientId: RecipientId, + val title: String?, + serializedMembers: String?, + serializedUnmigratedV1Members: String?, + val avatarId: Long, + val avatarKey: ByteArray?, + val avatarContentType: String?, + val relay: String?, + val isActive: Boolean, + val avatarDigest: ByteArray?, + val isMms: Boolean, + groupMasterKeyBytes: ByteArray?, + groupRevision: Int, + decryptedGroupBytes: ByteArray?, + val distributionId: DistributionId?, + val lastForceUpdateTimestamp: Long +) { + + val members: List by lazy { + if (serializedMembers.isNullOrEmpty()) { + emptyList() + } else { + RecipientId.fromSerializedList(serializedMembers) + } + } + + /** V1 members that were lost during the V1->V2 migration */ + val unmigratedV1Members: List by lazy { + if (serializedUnmigratedV1Members.isNullOrEmpty()) { + emptyList() + } else { + RecipientId.fromSerializedList(serializedUnmigratedV1Members) + } + } + + private val v2GroupProperties: GroupTable.V2GroupProperties? by lazy { + if (groupMasterKeyBytes != null && decryptedGroupBytes != null) { + val groupMasterKey = GroupMasterKey(groupMasterKeyBytes) + GroupTable.V2GroupProperties(groupMasterKey, groupRevision, decryptedGroupBytes) + } else { + null + } + } + + val description: String + get() = v2GroupProperties?.decryptedGroup?.description ?: "" + + val isAnnouncementGroup: Boolean + get() = v2GroupProperties?.decryptedGroup?.isAnnouncementGroup == EnabledState.ENABLED + + val isV1Group: Boolean + get() = !isMms && !isV2Group + + val isV2Group: Boolean + get() = v2GroupProperties != null + + @get:WorkerThread + val admins: List + get() { + return if (v2GroupProperties != null) { + val resolved = members.map { Recipient.resolved(it) } + v2GroupProperties!!.getAdmins(resolved) + } else { + emptyList() + } + } + + /** Who is allowed to add to the membership of this group. */ + val membershipAdditionAccessControl: GroupAccessControl + get() { + return if (isV2Group) { + if (requireV2GroupProperties().decryptedGroup.accessControl.members == AccessControl.AccessRequired.MEMBER) { + GroupAccessControl.ALL_MEMBERS + } else { + GroupAccessControl.ONLY_ADMINS + } + } else if (isV1Group) { + GroupAccessControl.NO_ONE + } else if (id.isV1) { + GroupAccessControl.ALL_MEMBERS + } else { + GroupAccessControl.ONLY_ADMINS + } + } + + /** Who is allowed to modify the attributes of this group, name/avatar/timer etc. */ + val attributesAccessControl: GroupAccessControl + get() { + return if (isV2Group) { + if (requireV2GroupProperties().decryptedGroup.accessControl.attributes == AccessControl.AccessRequired.MEMBER) { + GroupAccessControl.ALL_MEMBERS + } else { + GroupAccessControl.ONLY_ADMINS + } + } else if (isV1Group) { + GroupAccessControl.NO_ONE + } else { + GroupAccessControl.ALL_MEMBERS + } + } + + fun hasAvatar(): Boolean { + return avatarId != 0L + } + + fun requireV2GroupProperties(): GroupTable.V2GroupProperties { + return v2GroupProperties ?: throw AssertionError() + } + + fun isAdmin(recipient: Recipient): Boolean { + return isV2Group && requireV2GroupProperties().isAdmin(recipient) + } + + fun memberLevel(recipient: Recipient): GroupTable.MemberLevel { + return if (isV2Group) { + val memberLevel = requireV2GroupProperties().memberLevel(recipient.serviceId) + if (recipient.isSelf && memberLevel == GroupTable.MemberLevel.NOT_A_MEMBER) { + requireV2GroupProperties().memberLevel(Optional.ofNullable(SignalStore.account().pni)) + } else { + memberLevel + } + } else if (isMms && recipient.isSelf) { + GroupTable.MemberLevel.FULL_MEMBER + } else if (members.contains(recipient.id)) { + GroupTable.MemberLevel.FULL_MEMBER + } else { + GroupTable.MemberLevel.NOT_A_MEMBER + } + } + + /** + * Whether or not the recipient is a pending member. + */ + fun isPendingMember(recipient: Recipient): Boolean { + if (isV2Group) { + val serviceId = recipient.serviceId + if (serviceId.isPresent) { + return DecryptedGroupUtil.findPendingByUuid(requireV2GroupProperties().decryptedGroup.pendingMembersList, serviceId.get().uuid()) + .isPresent + } + } + return false + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountRepository.java b/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountRepository.java index e95f67896c..196b23eb3d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountRepository.java @@ -10,6 +10,7 @@ import org.signal.core.util.concurrent.SignalExecutors; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.database.GroupTable; import org.thoughtcrime.securesms.database.SignalDatabase; +import org.thoughtcrime.securesms.database.model.GroupRecord; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.groups.GroupManager; import org.thoughtcrime.securesms.keyvalue.SignalStore; @@ -78,7 +79,7 @@ class DeleteAccountRepository { int groupsLeft = 0; try (GroupTable.Reader groups = SignalDatabase.groups().getGroups()) { - GroupTable.GroupRecord groupRecord = groups.getNext(); + GroupRecord groupRecord = groups.getNext(); onDeleteAccountEvent.accept(new DeleteAccountEvent.LeaveGroupsProgress(groups.getCount(), 0)); Log.i(TAG, "deleteAccount: found " + groups.getCount() + " groups to leave."); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java index 767e02c05c..c039e17a7d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java @@ -15,6 +15,7 @@ import org.signal.storageservice.protos.groups.local.DecryptedGroup; import org.signal.storageservice.protos.groups.local.DecryptedGroupJoinInfo; import org.thoughtcrime.securesms.database.GroupTable; import org.thoughtcrime.securesms.database.SignalDatabase; +import org.thoughtcrime.securesms.database.model.GroupRecord; import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl; import org.thoughtcrime.securesms.groups.v2.GroupLinkPassword; import org.thoughtcrime.securesms.profiles.AvatarHelper; @@ -402,14 +403,14 @@ public final class GroupManager { throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException, GroupChangeBusyException, MembershipNotSuitableForV2Exception { if (groupId.isV2()) { - GroupTable.GroupRecord groupRecord = SignalDatabase.groups().requireGroup(groupId); + GroupRecord groupRecord = SignalDatabase.groups().requireGroup(groupId); try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) { return editor.addMembers(newMembers, groupRecord.requireV2GroupProperties().getBannedMembers()); } } else { - GroupTable.GroupRecord groupRecord = SignalDatabase.groups().requireGroup(groupId); - List members = groupRecord.getMembers(); + GroupRecord groupRecord = SignalDatabase.groups().requireGroup(groupId); + List members = groupRecord.getMembers(); byte[] avatar = groupRecord.hasAvatar() ? AvatarHelper.getAvatarBytes(context, groupRecord.getRecipientId()) : null; Set recipientIds = new HashSet<>(members); int originalSize = recipientIds.size(); 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 aea2c25d7c..b05ac45536 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java @@ -35,6 +35,7 @@ import org.thoughtcrime.securesms.crypto.ProfileKeyUtil; import org.thoughtcrime.securesms.database.GroupTable; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.ThreadTable; +import org.thoughtcrime.securesms.database.model.GroupRecord; import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.groups.v2.GroupCandidateHelper; @@ -167,8 +168,8 @@ final class GroupManagerV2 { @WorkerThread @NonNull Map getUuidCipherTexts(@NonNull GroupId.V2 groupId) { - GroupTable.GroupRecord groupRecord = SignalDatabase.groups().requireGroup(groupId); - GroupTable.V2GroupProperties v2GroupProperties = groupRecord.requireV2GroupProperties(); + GroupRecord groupRecord = SignalDatabase.groups().requireGroup(groupId); + GroupTable.V2GroupProperties v2GroupProperties = groupRecord.requireV2GroupProperties(); GroupMasterKey groupMasterKey = v2GroupProperties.getGroupMasterKey(); ClientZkGroupCipher clientZkGroupCipher = new ClientZkGroupCipher(GroupSecretParams.deriveFromMasterKey(groupMasterKey)); List recipients = v2GroupProperties.getMemberRecipients(GroupTable.MemberSet.FULL_MEMBERS_INCLUDING_SELF); @@ -251,9 +252,9 @@ final class GroupManagerV2 { throws IOException, MembershipNotSuitableForV2Exception, GroupAlreadyExistsException, GroupChangeFailedException { GroupMasterKey groupMasterKey = groupIdV1.deriveV2MigrationMasterKey(); - GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey); - GroupTable.GroupRecord groupRecord = groupDatabase.requireGroup(groupIdV1); - String name = Util.emptyIfNull(groupRecord.getTitle()); + GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey); + GroupRecord groupRecord = groupDatabase.requireGroup(groupIdV1); + String name = Util.emptyIfNull(groupRecord.getTitle()); byte[] avatar = groupRecord.hasAvatar() ? AvatarHelper.getAvatarBytes(context, groupRecord.getRecipientId()) : null; int messageTimer = Recipient.resolved(groupRecord.getRecipientId()).getExpiresInSeconds(); Set memberIds = Stream.of(members) @@ -327,7 +328,7 @@ final class GroupManagerV2 { GroupEditor(@NonNull GroupId.V2 groupId, @NonNull Closeable lock) { super(lock); - GroupTable.GroupRecord groupRecord = groupDatabase.requireGroup(groupId); + GroupRecord groupRecord = groupDatabase.requireGroup(groupId); this.groupId = groupId; this.v2GroupProperties = groupRecord.requireV2GroupProperties(); @@ -455,8 +456,8 @@ final class GroupManagerV2 { void leaveGroup() throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException { - GroupTable.GroupRecord groupRecord = groupDatabase.requireGroup(groupId); - DecryptedGroup decryptedGroup = groupRecord.requireV2GroupProperties().getDecryptedGroup(); + GroupRecord groupRecord = groupDatabase.requireGroup(groupId); + DecryptedGroup decryptedGroup = groupRecord.requireV2GroupProperties().getDecryptedGroup(); Optional selfMember = DecryptedGroupUtil.findMemberByUuid(decryptedGroup.getMembersList(), selfAci.uuid()); Optional aciPendingMember = DecryptedGroupUtil.findPendingByUuid(decryptedGroup.getPendingMembersList(), selfAci.uuid()); Optional pniPendingMember = DecryptedGroupUtil.findPendingByUuid(decryptedGroup.getPendingMembersList(), selfPni.uuid()); @@ -728,7 +729,7 @@ final class GroupManagerV2 { private GroupManager.GroupActionResult commitChange(@NonNull GroupChange.Actions.Builder change, boolean allowWhenBlocked, boolean sendToMembers) throws GroupNotAMemberException, GroupChangeFailedException, IOException, GroupInsufficientRightsException { - final GroupTable.GroupRecord groupRecord = groupDatabase.requireGroup(groupId); + final GroupRecord groupRecord = groupDatabase.requireGroup(groupId); final GroupTable.V2GroupProperties v2GroupProperties = groupRecord.requireV2GroupProperties(); final int nextRevision = v2GroupProperties.getGroupRevision() + 1; final GroupChange.Actions changeActions = change.setRevision(nextRevision).build(); @@ -923,7 +924,7 @@ final class GroupManagerV2 { alreadyAMember = true; } - Optional unmigratedV1Group = groupDatabase.getGroupV1ByExpectedV2(groupId); + Optional unmigratedV1Group = groupDatabase.getGroupV1ByExpectedV2(groupId); if (unmigratedV1Group.isPresent()) { Log.i(TAG, "Group link was for a migrated V1 group we know about! Migrating it and using that as the base."); @@ -932,7 +933,7 @@ final class GroupManagerV2 { DecryptedGroup decryptedGroup = createPlaceholderGroup(joinInfo, requestToJoin); - Optional group = groupDatabase.getGroup(groupId); + Optional group = groupDatabase.getGroup(groupId); if (group.isPresent()) { Log.i(TAG, "Group already present locally"); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java b/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java index e08a97cc97..2e469b2a81 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java @@ -19,6 +19,7 @@ import org.signal.storageservice.protos.groups.local.DecryptedRequestingMember; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.database.GroupTable; import org.thoughtcrime.securesms.database.SignalDatabase; +import org.thoughtcrime.securesms.database.model.GroupRecord; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry; import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl; @@ -48,7 +49,7 @@ public final class LiveGroup { private final GroupTable groupDatabase; private final LiveData recipient; - private final LiveData groupRecord; + private final LiveData groupRecord; private final LiveData> fullMembers; private final LiveData> requestingMembers; private final LiveData groupLink; @@ -64,7 +65,7 @@ public final class LiveGroup { this.requestingMembers = mapToRequestingMembers(this.groupRecord); if (groupId.isV2()) { - LiveData v2Properties = Transformations.map(this.groupRecord, GroupTable.GroupRecord::requireV2GroupProperties); + LiveData v2Properties = Transformations.map(this.groupRecord, GroupRecord::requireV2GroupProperties); this.groupLink = Transformations.map(v2Properties, g -> { DecryptedGroup group = g.getDecryptedGroup(); AccessControl.AccessRequired addFromInviteLink = group.getAccessControl().getAddFromInviteLink(); @@ -87,7 +88,7 @@ public final class LiveGroup { SignalExecutors.BOUNDED.execute(() -> liveRecipient.postValue(Recipient.externalGroupExact(groupId).live())); } - protected static LiveData> mapToFullMembers(@NonNull LiveData groupRecord) { + protected static LiveData> mapToFullMembers(@NonNull LiveData groupRecord) { return LiveDataUtil.mapAsync(groupRecord, g -> Stream.of(g.getMembers()) .map(m -> { @@ -98,7 +99,7 @@ public final class LiveGroup { .toList()); } - protected static LiveData> mapToRequestingMembers(@NonNull LiveData groupRecord) { + protected static LiveData> mapToRequestingMembers(@NonNull LiveData groupRecord) { return LiveDataUtil.mapAsync(groupRecord, g -> { if (!g.isV2Group()) { @@ -128,11 +129,11 @@ public final class LiveGroup { } public LiveData getDescription() { - return Transformations.map(groupRecord, GroupTable.GroupRecord::getDescription); + return Transformations.map(groupRecord, GroupRecord::getDescription); } public LiveData isAnnouncementGroup() { - return Transformations.map(groupRecord, GroupTable.GroupRecord::isAnnouncementGroup); + return Transformations.map(groupRecord, GroupRecord::isAnnouncementGroup); } public LiveData getGroupRecipient() { @@ -148,7 +149,7 @@ public final class LiveGroup { } public LiveData isActive() { - return Transformations.map(groupRecord, GroupTable.GroupRecord::isActive); + return Transformations.map(groupRecord, GroupRecord::isActive); } public LiveData getRecipientIsAdmin(@NonNull RecipientId recipientId) { @@ -171,11 +172,11 @@ public final class LiveGroup { } public LiveData getMembershipAdditionAccessControl() { - return Transformations.map(groupRecord, GroupTable.GroupRecord::getMembershipAdditionAccessControl); + return Transformations.map(groupRecord, GroupRecord::getMembershipAdditionAccessControl); } public LiveData getAttributesAccessControl() { - return Transformations.map(groupRecord, GroupTable.GroupRecord::getAttributesAccessControl); + return Transformations.map(groupRecord, GroupRecord::getAttributesAccessControl); } public LiveData> getNonAdminFullMembers() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/LeaveGroupDialog.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/LeaveGroupDialog.java index 8a02a22c98..a63b24e618 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/LeaveGroupDialog.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/LeaveGroupDialog.java @@ -14,6 +14,7 @@ import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.database.GroupTable; import org.thoughtcrime.securesms.database.SignalDatabase; +import org.thoughtcrime.securesms.database.model.GroupRecord; import org.thoughtcrime.securesms.groups.GroupChangeException; import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.groups.GroupManager; @@ -56,7 +57,7 @@ public final class LeaveGroupDialog { SimpleTask.run(activity.getLifecycle(), () -> { GroupTable.V2GroupProperties groupProperties = SignalDatabase.groups() .getGroup(groupId) - .map(GroupTable.GroupRecord::requireV2GroupProperties) + .map(GroupRecord::requireV2GroupProperties) .orElse(null); if (groupProperties != null && groupProperties.isAdmin(Recipient.self())) { 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 e36f819d93..267ce64ca8 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 @@ -20,7 +20,7 @@ import org.signal.storageservice.protos.groups.local.DecryptedMember; import org.signal.storageservice.protos.groups.local.DecryptedPendingMember; import org.signal.storageservice.protos.groups.local.DecryptedPendingMemberRemoval; import org.thoughtcrime.securesms.database.GroupTable; -import org.thoughtcrime.securesms.database.GroupTable.GroupRecord; +import org.thoughtcrime.securesms.database.model.GroupRecord; import org.thoughtcrime.securesms.database.MessageTable; import org.thoughtcrime.securesms.database.RecipientTable; import org.thoughtcrime.securesms.database.SignalDatabase; diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/migrations/SenderKeyDistributionSendJobRecipientMigration.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/migrations/SenderKeyDistributionSendJobRecipientMigration.java index 9adc989860..e8c59ef25e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/migrations/SenderKeyDistributionSendJobRecipientMigration.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/migrations/SenderKeyDistributionSendJobRecipientMigration.java @@ -5,7 +5,7 @@ import androidx.annotation.VisibleForTesting; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.database.GroupTable; -import org.thoughtcrime.securesms.database.GroupTable.GroupRecord; +import org.thoughtcrime.securesms.database.model.GroupRecord; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.jobmanager.Data; diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/AvatarGroupsV1DownloadJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/AvatarGroupsV1DownloadJob.java index d55a3906aa..025d8cc91e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/AvatarGroupsV1DownloadJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/AvatarGroupsV1DownloadJob.java @@ -5,7 +5,7 @@ import androidx.annotation.NonNull; import org.signal.core.util.logging.Log; import org.signal.libsignal.protocol.InvalidMessageException; import org.thoughtcrime.securesms.database.GroupTable; -import org.thoughtcrime.securesms.database.GroupTable.GroupRecord; +import org.thoughtcrime.securesms.database.model.GroupRecord; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.groups.GroupId; diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/AvatarGroupsV2DownloadJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/AvatarGroupsV2DownloadJob.java index 6c9e2ffb0c..7f46ce2cbd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/AvatarGroupsV2DownloadJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/AvatarGroupsV2DownloadJob.java @@ -10,7 +10,7 @@ import org.signal.core.util.logging.Log; import org.signal.libsignal.zkgroup.groups.GroupMasterKey; import org.signal.libsignal.zkgroup.groups.GroupSecretParams; import org.thoughtcrime.securesms.database.GroupTable; -import org.thoughtcrime.securesms.database.GroupTable.GroupRecord; +import org.thoughtcrime.securesms.database.model.GroupRecord; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.groups.GroupId; diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/ForceUpdateGroupV2Job.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/ForceUpdateGroupV2Job.java index 3d5c258e23..9d4db0dc13 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/ForceUpdateGroupV2Job.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/ForceUpdateGroupV2Job.java @@ -3,8 +3,8 @@ package org.thoughtcrime.securesms.jobs; import androidx.annotation.NonNull; import org.signal.core.util.concurrent.SignalExecutors; -import org.thoughtcrime.securesms.database.GroupTable; import org.thoughtcrime.securesms.database.SignalDatabase; +import org.thoughtcrime.securesms.database.model.GroupRecord; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.jobmanager.Data; @@ -28,7 +28,7 @@ public final class ForceUpdateGroupV2Job extends BaseJob { public static void enqueueIfNecessary(@NonNull GroupId.V2 groupId) { SignalExecutors.BOUNDED.execute(() -> { - Optional group = SignalDatabase.groups().getGroup(groupId); + Optional group = SignalDatabase.groups().getGroup(groupId); if (group.isPresent() && group.get().isV2Group() && group.get().getLastForceUpdateTimestamp() + FORCE_UPDATE_INTERVAL < System.currentTimeMillis() diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/ForceUpdateGroupV2WorkerJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/ForceUpdateGroupV2WorkerJob.java index d9e37cf117..297e74724b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/ForceUpdateGroupV2WorkerJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/ForceUpdateGroupV2WorkerJob.java @@ -3,8 +3,8 @@ package org.thoughtcrime.securesms.jobs; import androidx.annotation.NonNull; import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.database.GroupTable; import org.thoughtcrime.securesms.database.SignalDatabase; +import org.thoughtcrime.securesms.database.model.GroupRecord; import org.thoughtcrime.securesms.groups.GroupChangeBusyException; import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.groups.GroupManager; @@ -61,7 +61,7 @@ final class ForceUpdateGroupV2WorkerJob extends BaseJob { @Override public void onRun() throws IOException, GroupNotAMemberException, GroupChangeBusyException { - Optional group = SignalDatabase.groups().getGroup(groupId); + Optional group = SignalDatabase.groups().getGroup(groupId); if (!group.isPresent()) { Log.w(TAG, "Group not found"); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/GroupV2UpdateSelfProfileKeyJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/GroupV2UpdateSelfProfileKeyJob.java index 74740e3bdb..eb58c8d45d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/GroupV2UpdateSelfProfileKeyJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/GroupV2UpdateSelfProfileKeyJob.java @@ -8,8 +8,8 @@ import com.google.protobuf.ByteString; import org.signal.core.util.concurrent.SignalExecutors; import org.signal.core.util.logging.Log; import org.signal.storageservice.protos.groups.local.DecryptedMember; -import org.thoughtcrime.securesms.database.GroupTable; import org.thoughtcrime.securesms.database.SignalDatabase; +import org.thoughtcrime.securesms.database.model.GroupRecord; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.groups.GroupChangeBusyException; import org.thoughtcrime.securesms.groups.GroupChangeFailedException; @@ -112,7 +112,7 @@ public final class GroupV2UpdateSelfProfileKeyJob extends BaseJob { boolean foundMismatch = false; for (GroupId.V2 id : SignalDatabase.groups().getAllGroupV2Ids()) { - Optional group = SignalDatabase.groups().getGroup(id); + Optional group = SignalDatabase.groups().getGroup(id); if (!group.isPresent()) { Log.w(TAG, "Group " + group + " no longer exists?"); continue; diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceGroupUpdateJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceGroupUpdateJob.java index 2c8d51d40d..ef28214c73 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceGroupUpdateJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceGroupUpdateJob.java @@ -10,6 +10,7 @@ import org.thoughtcrime.securesms.conversation.colors.ChatColorsMapper; import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; import org.thoughtcrime.securesms.database.GroupTable; import org.thoughtcrime.securesms.database.SignalDatabase; +import org.thoughtcrime.securesms.database.model.GroupRecord; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.jobmanager.Data; import org.thoughtcrime.securesms.jobmanager.Job; @@ -101,7 +102,7 @@ public class MultiDeviceGroupUpdateJob extends BaseJob { DeviceGroupsOutputStream out = new DeviceGroupsOutputStream(new ParcelFileDescriptor.AutoCloseOutputStream(pipe[1])); boolean hasData = false; - GroupTable.GroupRecord record; + GroupRecord record; while ((record = reader.getNext()) != null) { if (record.isV1Group()) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java index d67a614ecf..d2a6273846 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java @@ -23,6 +23,7 @@ import org.thoughtcrime.securesms.database.RecipientTable; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch; import org.thoughtcrime.securesms.database.documents.NetworkFailure; +import org.thoughtcrime.securesms.database.model.GroupRecord; import org.thoughtcrime.securesms.database.model.MessageId; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; @@ -252,7 +253,7 @@ public final class PushGroupSendJob extends PushSendJob { .anyMatch(info -> info.getStatus() > GroupReceiptTable.STATUS_UNDELIVERED); if (message.getStoryType().isStory()) { - Optional groupRecord = SignalDatabase.groups().getGroup(groupId); + Optional groupRecord = SignalDatabase.groups().getGroup(groupId); if (groupRecord.isPresent() && groupRecord.get().isAnnouncementGroup() && !groupRecord.get().isAdmin(Recipient.self())) { throw new UndeliverableMessageException("Non-admins cannot send stories in announcement groups!"); @@ -301,7 +302,7 @@ public final class PushGroupSendJob extends PushSendJob { throw new UndeliverableMessageException("Messages can no longer be sent to V1 groups!"); } } else { - Optional groupRecord = SignalDatabase.groups().getGroup(groupRecipient.requireGroupId()); + Optional groupRecord = SignalDatabase.groups().getGroup(groupRecipient.requireGroupId()); if (groupRecord.isPresent() && groupRecord.get().isAnnouncementGroup() && !groupRecord.get().isAdmin(Recipient.self())) { throw new UndeliverableMessageException("Non-admins cannot send messages in announcement groups!"); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RequestGroupV2InfoWorkerJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RequestGroupV2InfoWorkerJob.java index 79c6a3b7c2..1c8a8f85b9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RequestGroupV2InfoWorkerJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RequestGroupV2InfoWorkerJob.java @@ -4,8 +4,8 @@ import androidx.annotation.NonNull; import androidx.annotation.WorkerThread; import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.database.GroupTable; import org.thoughtcrime.securesms.database.SignalDatabase; +import org.thoughtcrime.securesms.database.model.GroupRecord; import org.thoughtcrime.securesms.groups.GroupChangeBusyException; import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.groups.GroupManager; @@ -76,7 +76,7 @@ final class RequestGroupV2InfoWorkerJob extends BaseJob { Log.i(TAG, "Updating group to revision " + toRevision); } - Optional group = SignalDatabase.groups().getGroup(groupId); + Optional group = SignalDatabase.groups().getGroup(groupId); if (!group.isPresent()) { Log.w(TAG, "Group not found"); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/ResendMessageJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/ResendMessageJob.java index 49c86acd46..000d20f116 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/ResendMessageJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/ResendMessageJob.java @@ -11,7 +11,7 @@ import org.signal.core.util.logging.Log; import org.signal.libsignal.protocol.SignalProtocolAddress; import org.signal.libsignal.protocol.message.SenderKeyDistributionMessage; import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; -import org.thoughtcrime.securesms.database.GroupTable.GroupRecord; +import org.thoughtcrime.securesms.database.model.GroupRecord; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.model.DistributionListRecord; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewRepository.java b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewRepository.java index c55324d10e..30fdc2c5fd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewRepository.java @@ -23,8 +23,8 @@ import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.attachments.Attachment; import org.thoughtcrime.securesms.attachments.UriAttachment; import org.thoughtcrime.securesms.database.AttachmentTable; -import org.thoughtcrime.securesms.database.GroupTable; import org.thoughtcrime.securesms.database.SignalDatabase; +import org.thoughtcrime.securesms.database.model.GroupRecord; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.groups.GroupManager; @@ -282,14 +282,14 @@ public class LinkPreviewRepository { } GroupMasterKey groupMasterKey = groupInviteLinkUrl.getGroupMasterKey(); - GroupId.V2 groupId = GroupId.v2(groupMasterKey); - Optional group = SignalDatabase.groups().getGroup(groupId); + GroupId.V2 groupId = GroupId.v2(groupMasterKey); + Optional group = SignalDatabase.groups().getGroup(groupId); if (group.isPresent()) { Log.i(TAG, "Creating preview for locally available group"); - GroupTable.GroupRecord groupRecord = group.get(); - String title = groupRecord.getTitle(); + GroupRecord groupRecord = group.get(); + String title = groupRecord.getTitle(); int memberCount = groupRecord.getMembers().size(); String description = getMemberCountDescription(context, memberCount); Optional thumbnail = Optional.empty(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraContactsRepository.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraContactsRepository.java index 192f12c53f..4beb5028a8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraContactsRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraContactsRepository.java @@ -15,6 +15,7 @@ import org.thoughtcrime.securesms.database.GroupTable; import org.thoughtcrime.securesms.database.RecipientTable; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.ThreadTable; +import org.thoughtcrime.securesms.database.model.GroupRecord; import org.thoughtcrime.securesms.database.model.ThreadRecord; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; @@ -121,7 +122,7 @@ class CameraContactsRepository { List recipients = new ArrayList<>(); try (GroupTable.Reader reader = groupDatabase.queryGroupsByTitle(query, false, true, true)) { - GroupTable.GroupRecord groupRecord; + GroupRecord groupRecord; while ((groupRecord = reader.getNext()) != null) { RecipientId recipientId = recipientTable.getOrInsertFromGroupId(groupRecord.getId()); recipients.add(Recipient.resolved(recipientId)); diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java index 352d16cf0c..f1ed86f2f6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java @@ -14,6 +14,7 @@ import org.thoughtcrime.securesms.database.MessageTable; import org.thoughtcrime.securesms.database.RecipientTable; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.ThreadTable; +import org.thoughtcrime.securesms.database.model.GroupRecord; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.groups.GroupChangeException; import org.thoughtcrime.securesms.groups.GroupManager; @@ -58,8 +59,8 @@ final class MessageRequestRepository { void getGroupInfo(@NonNull RecipientId recipientId, @NonNull Consumer onGroupInfoLoaded) { executor.execute(() -> { - GroupTable groupDatabase = SignalDatabase.groups(); - Optional groupRecord = groupDatabase.getGroup(recipientId); + GroupTable groupDatabase = SignalDatabase.groups(); + Optional groupRecord = groupDatabase.getGroup(recipientId); onGroupInfoLoaded.accept(groupRecord.map(record -> { if (record.isV2Group()) { DecryptedGroup decryptedGroup = record.requireV2GroupProperties().getDecryptedGroup(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/GroupSendUtil.java b/app/src/main/java/org/thoughtcrime/securesms/messages/GroupSendUtil.java index 3371654195..d3123e66d2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/GroupSendUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/GroupSendUtil.java @@ -12,7 +12,7 @@ import org.signal.libsignal.protocol.InvalidRegistrationIdException; import org.signal.libsignal.protocol.NoSessionException; import org.thoughtcrime.securesms.crypto.SenderKeyUtil; import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; -import org.thoughtcrime.securesms.database.GroupTable.GroupRecord; +import org.thoughtcrime.securesms.database.model.GroupRecord; import org.thoughtcrime.securesms.database.MessageSendLogTables; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.model.DistributionListId; diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java index 522613ffff..41bffc125a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java @@ -35,7 +35,7 @@ import org.thoughtcrime.securesms.crypto.SecurityEvent; import org.thoughtcrime.securesms.database.AttachmentTable; import org.thoughtcrime.securesms.database.CallTable; import org.thoughtcrime.securesms.database.GroupTable; -import org.thoughtcrime.securesms.database.GroupTable.GroupRecord; +import org.thoughtcrime.securesms.database.model.GroupRecord; import org.thoughtcrime.securesms.database.GroupReceiptTable; import org.thoughtcrime.securesms.database.GroupReceiptTable.GroupReceiptInfo; import org.thoughtcrime.securesms.database.MessageTable; diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationBuilder.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationBuilder.kt index 89c03511d8..24136b5564 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationBuilder.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationBuilder.kt @@ -18,9 +18,9 @@ import androidx.core.graphics.drawable.IconCompat import org.signal.core.util.PendingIntentFlags.mutable import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.conversation.ConversationIntents -import org.thoughtcrime.securesms.database.GroupTable import org.thoughtcrime.securesms.database.RecipientTable import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.GroupRecord import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.notifications.NotificationChannels import org.thoughtcrime.securesms.notifications.ReplyMethod @@ -117,7 +117,7 @@ sealed class NotificationBuilder(protected val context: Context) { fun addReplyActions(conversation: NotificationConversation) { if (privacy.isDisplayMessage && isNotLocked && !conversation.recipient.isPushV1Group && RecipientUtil.isMessageRequestAccepted(context, conversation.recipient)) { if (conversation.recipient.isPushV2Group) { - val group: Optional = SignalDatabase.groups.getGroup(conversation.recipient.requireGroupId()) + val group: Optional = SignalDatabase.groups.getGroup(conversation.recipient.requireGroupId()) if (group.isPresent && group.get().isAnnouncementGroup && !group.get().isAdmin(Recipient.self())) { return } diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditGroupProfileRepository.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditGroupProfileRepository.java index 507a3dc0a2..83a431bc2d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditGroupProfileRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditGroupProfileRepository.java @@ -10,8 +10,8 @@ import androidx.core.util.Consumer; import org.signal.core.util.StreamUtil; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.conversation.colors.AvatarColor; -import org.thoughtcrime.securesms.database.GroupTable; import org.thoughtcrime.securesms.database.SignalDatabase; +import org.thoughtcrime.securesms.database.model.GroupRecord; import org.thoughtcrime.securesms.groups.GroupChangeException; import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.groups.GroupManager; @@ -92,7 +92,7 @@ class EditGroupProfileRepository implements EditProfileRepository { return SignalDatabase.groups() .getGroup(recipientId) - .map(GroupTable.GroupRecord::getDescription) + .map(GroupRecord::getDescription) .orElse(""); }, descriptionConsumer::accept); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewUtil.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewUtil.java index e857d03f8b..22ab639b20 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewUtil.java @@ -9,6 +9,7 @@ import com.annimon.stream.Stream; import org.thoughtcrime.securesms.database.GroupTable; import org.thoughtcrime.securesms.database.SignalDatabase; +import org.thoughtcrime.securesms.database.model.GroupRecord; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; @@ -105,7 +106,7 @@ public final class ReviewUtil { return Stream.of(SignalDatabase.groups() .getPushGroupsContainingMember(recipientId)) .filter(g -> g.getMembers().contains(Recipient.self().getId())) - .map(GroupTable.GroupRecord::getRecipientId) + .map(GroupRecord::getRecipientId) .toList() .size(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/LiveRecipient.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/LiveRecipient.java index a5619b6733..fd9d29a51c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/LiveRecipient.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/LiveRecipient.java @@ -15,7 +15,7 @@ import org.signal.core.util.ThreadUtil; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.database.DistributionListTables; import org.thoughtcrime.securesms.database.GroupTable; -import org.thoughtcrime.securesms.database.GroupTable.GroupRecord; +import org.thoughtcrime.securesms.database.model.GroupRecord; import org.thoughtcrime.securesms.database.RecipientTable; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.model.DistributionListRecord; diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientId.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientId.java index 7d37b2a560..9227efd698 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientId.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientId.java @@ -21,6 +21,7 @@ import org.whispersystems.signalservice.api.util.UuidUtil; import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; import java.util.List; import java.util.regex.Pattern; diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientUtil.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientUtil.java index 043be58f00..142f64f3bf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientUtil.java @@ -14,6 +14,7 @@ import org.thoughtcrime.securesms.database.GroupTable; import org.thoughtcrime.securesms.database.RecipientTable.RegisteredState; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.ThreadTable; +import org.thoughtcrime.securesms.database.model.GroupRecord; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.groups.GroupChangeBusyException; import org.thoughtcrime.securesms.groups.GroupChangeException; @@ -301,7 +302,7 @@ public class RecipientUtil { GroupTable groupDatabase = SignalDatabase.groups(); return groupDatabase.getPushGroupsContainingMember(recipient.getId()) .stream() - .anyMatch(GroupTable.GroupRecord::isV2Group); + .anyMatch(GroupRecord::isV2Group); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogRepository.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogRepository.java index 0200e63cd6..f08efa7f1b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogRepository.java @@ -11,6 +11,7 @@ import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery; import org.thoughtcrime.securesms.database.GroupTable; import org.thoughtcrime.securesms.database.SignalDatabase; +import org.thoughtcrime.securesms.database.model.GroupRecord; import org.thoughtcrime.securesms.database.model.IdentityRecord; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.groups.GroupChangeException; @@ -106,11 +107,11 @@ final class RecipientDialogRepository { void getGroupMembership(@NonNull Consumer> onComplete) { SimpleTask.run(SignalExecutors.UNBOUNDED, () -> { - GroupTable groupDatabase = SignalDatabase.groups(); - List groupRecords = groupDatabase.getPushGroupsContainingMember(recipientId); - ArrayList groupRecipients = new ArrayList<>(groupRecords.size()); + GroupTable groupDatabase = SignalDatabase.groups(); + List groupRecords = groupDatabase.getPushGroupsContainingMember(recipientId); + ArrayList groupRecipients = new ArrayList<>(groupRecords.size()); - for (GroupTable.GroupRecord groupRecord : groupRecords) { + for (GroupRecord groupRecord : groupRecords) { groupRecipients.add(groupRecord.getRecipientId()); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java b/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java index 02a5dc9dc1..f06807b170 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java @@ -23,6 +23,7 @@ import org.thoughtcrime.securesms.database.RecipientTable; import org.thoughtcrime.securesms.database.SearchTable; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.ThreadTable; +import org.thoughtcrime.securesms.database.model.GroupRecord; import org.thoughtcrime.securesms.database.model.Mention; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.ThreadRecord; @@ -174,7 +175,7 @@ public class SearchRepository { Set groupsByTitleIds = new LinkedHashSet<>(); - GroupTable.GroupRecord record; + GroupRecord record; try (GroupTable.Reader reader = SignalDatabase.groups().queryGroupsByTitle(query, true, false, false)) { while ((record = reader.getNext()) != null) { groupsByTitleIds.add(record.getRecipientId()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java index d9a970105a..75d1a3c9ae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java @@ -33,6 +33,7 @@ import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; import org.thoughtcrime.securesms.database.GroupTable; import org.thoughtcrime.securesms.database.CallTable; import org.thoughtcrime.securesms.database.SignalDatabase; +import org.thoughtcrime.securesms.database.model.GroupRecord; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.events.GroupCallPeekEvent; import org.thoughtcrime.securesms.events.WebRtcViewModel; @@ -748,9 +749,9 @@ private void processStateless(@NonNull Function1 v2Record = groupDatabase.getGroup(id.deriveV2MigrationGroupId()); + GroupId.V1 id = GroupId.v1(remote.getGroupId()); + Optional v2Record = groupDatabase.getGroup(id.deriveV2MigrationGroupId()); if (v2Record.isPresent()) { Log.w(TAG, "We already have an upgraded V2 group for this V1 group -- marking as invalid."); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java b/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java index f5255b0239..5f5e206b28 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java @@ -29,8 +29,8 @@ import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.WebRtcCallActivity; import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery; import org.thoughtcrime.securesms.conversation.ConversationIntents; -import org.thoughtcrime.securesms.database.GroupTable; import org.thoughtcrime.securesms.database.SignalDatabase; +import org.thoughtcrime.securesms.database.model.GroupRecord; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining.GroupJoinBottomSheetDialogFragment; @@ -237,7 +237,7 @@ public class CommunicationActions { GroupId.V2 groupId = GroupId.v2(groupInviteLinkUrl.getGroupMasterKey()); SimpleTask.run(SignalExecutors.BOUNDED, () -> { - GroupTable.GroupRecord group = SignalDatabase.groups().getGroup(groupId).orElse(null); + GroupRecord group = SignalDatabase.groups().getGroup(groupId).orElse(null); return group != null && group.isActive() ? Recipient.resolved(group.getRecipientId()) : null; diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/GroupUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/GroupUtil.java index 37bfbb9f02..2376c8ea18 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/GroupUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/GroupUtil.java @@ -14,6 +14,7 @@ import org.signal.libsignal.zkgroup.groups.GroupMasterKey; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.database.GroupTable; import org.thoughtcrime.securesms.database.SignalDatabase; +import org.thoughtcrime.securesms.database.model.GroupRecord; import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.mms.MessageGroupContext; import org.thoughtcrime.securesms.recipients.Recipient; @@ -94,7 +95,7 @@ public final class GroupUtil { { if (groupId.isV2()) { GroupTable groupDatabase = SignalDatabase.groups(); - GroupTable.GroupRecord groupRecord = groupDatabase.requireGroup(groupId); + GroupRecord groupRecord = groupDatabase.requireGroup(groupId); GroupTable.V2GroupProperties v2GroupProperties = groupRecord.requireV2GroupProperties(); SignalServiceGroupV2 group = SignalServiceGroupV2.newBuilder(v2GroupProperties.getGroupMasterKey()) .withRevision(v2GroupProperties.getGroupRevision()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/IdentityUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/IdentityUtil.java index ef61021b7f..40b0004b4c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/IdentityUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/IdentityUtil.java @@ -20,6 +20,7 @@ import org.thoughtcrime.securesms.database.IdentityTable; import org.thoughtcrime.securesms.database.MessageTable; import org.thoughtcrime.securesms.database.MessageTable.InsertResult; import org.thoughtcrime.securesms.database.SignalDatabase; +import org.thoughtcrime.securesms.database.model.GroupRecord; import org.thoughtcrime.securesms.database.model.IdentityRecord; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.keyvalue.SignalStore; @@ -67,7 +68,7 @@ public final class IdentityUtil { try (GroupTable.Reader reader = groupDatabase.getGroups()) { - GroupTable.GroupRecord groupRecord; + GroupRecord groupRecord; while ((groupRecord = reader.getNext()) != null) { if (groupRecord.getMembers().contains(recipient.getId()) && groupRecord.isActive() && !groupRecord.isMms()) { @@ -138,7 +139,7 @@ public final class IdentityUtil { GroupTable groupDatabase = SignalDatabase.groups(); try (GroupTable.Reader reader = groupDatabase.getGroups()) { - GroupTable.GroupRecord groupRecord; + GroupRecord groupRecord; while ((groupRecord = reader.getNext()) != null) { if (groupRecord.getMembers().contains(recipientId) && groupRecord.isActive()) { 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 14073c8f85..4acae3036b 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/database/GroupTestUtil.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/database/GroupTestUtil.kt @@ -13,6 +13,7 @@ import org.signal.storageservice.protos.groups.local.DecryptedRequestingMember import org.signal.storageservice.protos.groups.local.DecryptedString import org.signal.storageservice.protos.groups.local.DecryptedTimer import org.signal.storageservice.protos.groups.local.EnabledState +import org.thoughtcrime.securesms.database.model.GroupRecord import org.thoughtcrime.securesms.groups.GroupId import org.thoughtcrime.securesms.recipients.RecipientId import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupHistoryEntry @@ -99,7 +100,7 @@ class GroupChangeData(private val revision: Int, private val groupOperations: Gr class GroupStateTestData(private val masterKey: GroupMasterKey, private val groupOperations: GroupsV2Operations.GroupOperations? = null) { var localState: DecryptedGroup? = null - var groupRecord: Optional = Optional.empty() + var groupRecord: Optional = Optional.empty() var serverState: DecryptedGroup? = null var changeSet: ChangeSet? = null var groupChange: GroupChange? = null @@ -172,9 +173,9 @@ fun groupRecord( avatarDigest: ByteArray = ByteArray(0), mms: Boolean = false, distributionId: DistributionId? = null -): Optional { +): Optional { return Optional.of( - GroupTable.GroupRecord( + GroupRecord( id, recipientId, decryptedGroup.title, 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 79d1fbb674..e5855429f1 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 @@ -4,6 +4,10 @@ package org.thoughtcrime.securesms.groups import android.app.Application import androidx.test.core.app.ApplicationProvider +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers import org.hamcrest.Matchers.`is` @@ -11,9 +15,6 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import org.mockito.ArgumentCaptor -import org.mockito.Mockito -import org.mockito.Mockito.mock import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import org.signal.core.util.Hex @@ -71,8 +72,6 @@ class GroupManagerV2Test_edit { private lateinit var sendGroupUpdateHelper: GroupManagerV2.SendGroupUpdateHelper private lateinit var groupOperations: GroupsV2Operations.GroupOperations - private lateinit var patchedDecryptedGroup: ArgumentCaptor - private lateinit var manager: GroupManagerV2 @get:Rule @@ -87,17 +86,15 @@ class GroupManagerV2Test_edit { val clientZkOperations = ClientZkOperations(server.getServerPublicParams()) - groupTable = mock(GroupTable::class.java) - groupsV2API = mock(GroupsV2Api::class.java) + groupTable = mockk() + groupsV2API = mockk() groupsV2Operations = GroupsV2Operations(clientZkOperations, 1000) - groupsV2Authorization = mock(GroupsV2Authorization::class.java) - groupsV2StateProcessor = mock(GroupsV2StateProcessor::class.java) - groupCandidateHelper = mock(GroupCandidateHelper::class.java) - sendGroupUpdateHelper = mock(GroupManagerV2.SendGroupUpdateHelper::class.java) + groupsV2Authorization = mockk(relaxed = true) + groupsV2StateProcessor = mockk() + groupCandidateHelper = mockk() + sendGroupUpdateHelper = mockk() groupOperations = groupsV2Operations.forGroup(groupSecretParams) - patchedDecryptedGroup = ArgumentCaptor.forClass(DecryptedGroup::class.java) - manager = GroupManagerV2( ApplicationProvider.getApplicationContext(), groupTable, @@ -115,12 +112,11 @@ class GroupManagerV2Test_edit { val data = GroupStateTestData(masterKey, groupOperations) data.init() - Mockito.doReturn(data.groupRecord).`when`(groupTable).getGroup(groupId) - Mockito.doReturn(data.groupRecord.get()).`when`(groupTable).requireGroup(groupId) - - Mockito.doReturn(GroupManagerV2.RecipientAndThread(Recipient.UNKNOWN, 1)).`when`(sendGroupUpdateHelper).sendGroupUpdate(Mockito.eq(masterKey), Mockito.any(), Mockito.any(), Mockito.anyBoolean()) - - Mockito.doReturn(data.groupChange!!).`when`(groupsV2API).patchGroup(Mockito.any(), Mockito.any(), Mockito.any()) + every { groupTable.getGroup(groupId) } returns data.groupRecord + every { groupTable.requireGroup(groupId) } returns data.groupRecord.get() + every { groupTable.update(any(), any()) } returns Unit + every { sendGroupUpdateHelper.sendGroupUpdate(masterKey, any(), any(), any()) } returns GroupManagerV2.RecipientAndThread(Recipient.UNKNOWN, 1) + every { groupsV2API.patchGroup(any(), any(), any()) } returns data.groupChange!! } private fun editGroup(perform: GroupManagerV2.GroupEditor.() -> Unit) { @@ -128,8 +124,9 @@ class GroupManagerV2Test_edit { } private fun then(then: (DecryptedGroup) -> Unit) { - Mockito.verify(groupTable).update(Mockito.eq(groupId), patchedDecryptedGroup.capture()) - then(patchedDecryptedGroup.value) + val decryptedGroupArg = slot() + verify { groupTable.update(groupId, capture(decryptedGroupArg)) } + then(decryptedGroupArg.captured) } @Test 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 02913f3cd5..5d1a91db18 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 @@ -2,6 +2,10 @@ package org.thoughtcrime.securesms.groups.v2.processing import android.app.Application import androidx.test.core.app.ApplicationProvider +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.verify import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.both import org.hamcrest.Matchers.hasItem @@ -13,18 +17,6 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import org.mockito.ArgumentCaptor -import org.mockito.ArgumentMatchers.anyLong -import org.mockito.ArgumentMatchers.eq -import org.mockito.Mockito.any -import org.mockito.Mockito.doCallRealMethod -import org.mockito.Mockito.doReturn -import org.mockito.Mockito.isA -import org.mockito.Mockito.mock -import org.mockito.Mockito.reset -import org.mockito.Mockito.verify -import org.mockito.kotlin.anyOrNull -import org.mockito.kotlin.doNothing import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import org.signal.core.util.Hex.fromStringCondensed @@ -48,6 +40,7 @@ import org.thoughtcrime.securesms.database.setNewTitle import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.groups.GroupId import org.thoughtcrime.securesms.groups.GroupsV2Authorization +import org.thoughtcrime.securesms.jobmanager.JobManager import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger import org.thoughtcrime.securesms.testutil.SystemOutLogger @@ -77,6 +70,7 @@ class GroupsV2StateProcessorTest { private lateinit var groupsV2API: GroupsV2Api private lateinit var groupsV2Authorization: GroupsV2Authorization private lateinit var profileAndMessageHelper: GroupsV2StateProcessor.ProfileAndMessageHelper + private lateinit var jobManager: JobManager private lateinit var processor: GroupsV2StateProcessor.StateProcessorForGroup @@ -88,25 +82,29 @@ class GroupsV2StateProcessorTest { Log.initialize(SystemOutLogger()) SignalProtocolLoggerProvider.setProvider(CustomSignalProtocolLogger()) - groupTable = mock(GroupTable::class.java) - recipientTable = mock(RecipientTable::class.java) - groupsV2API = mock(GroupsV2Api::class.java) - groupsV2Authorization = mock(GroupsV2Authorization::class.java) - profileAndMessageHelper = mock(GroupsV2StateProcessor.ProfileAndMessageHelper::class.java) + groupTable = mockk(relaxed = true) + recipientTable = mockk() + groupsV2API = mockk() + groupsV2Authorization = mockk(relaxed = true) + profileAndMessageHelper = mockk(relaxed = true) + jobManager = mockk(relaxed = true) + + mockkStatic(ApplicationDependencies::class) + every { ApplicationDependencies.getJobManager() } returns jobManager processor = GroupsV2StateProcessor.StateProcessorForGroup(serviceIds, ApplicationProvider.getApplicationContext(), groupTable, groupsV2API, groupsV2Authorization, masterKey, profileAndMessageHelper) } @After fun tearDown() { - reset(ApplicationDependencies.getJobManager()) +// reset(ApplicationDependencies.getJobManager()) } private fun given(init: GroupStateTestData.() -> Unit) { val data = givenData(init) - doReturn(data.groupRecord).`when`(groupTable).getGroup(any(GroupId.V2::class.java)) - doReturn(!data.groupRecord.isPresent).`when`(groupTable).isUnknownGroup(any()) + every { groupTable.getGroup(any()) } returns data.groupRecord + every { groupTable.isUnknownGroup(any()) } returns !data.groupRecord.isPresent data.serverState?.let { serverState -> val testPartial = object : PartialDecryptedGroup(null, serverState, null, null) { @@ -114,12 +112,13 @@ class GroupsV2StateProcessorTest { return serverState } } - doReturn(testPartial).`when`(groupsV2API).getPartialDecryptedGroup(any(), any()) - doReturn(serverState).`when`(groupsV2API).getGroup(any(), any()) + + every { groupsV2API.getPartialDecryptedGroup(any(), any()) } returns testPartial + every { groupsV2API.getGroup(any(), any()) } returns serverState } data.changeSet?.let { changeSet -> - doReturn(changeSet.toApiResponse()).`when`(groupsV2API).getGroupHistoryPage(any(), eq(data.requestedRevision), any(), eq(data.includeFirst)) + every { groupsV2API.getGroupHistoryPage(any(), data.requestedRevision, any(), data.includeFirst) } returns changeSet.toApiResponse() } } @@ -306,15 +305,14 @@ class GroupsV2StateProcessorTest { apiCallParameters(2, true) } - doReturn(true).`when`(groupTable).isUnknownGroup(any()) + every { groupTable.isUnknownGroup(any()) } returns true val result = processor.updateLocalGroupToRevision(2, 0, DecryptedGroupChange.getDefaultInstance()) assertThat("local should update to revision added", result.groupState, `is`(GroupsV2StateProcessor.GroupState.GROUP_UPDATED)) assertThat("revision matches peer revision added", result.latestServer!!.revision, `is`(2)) assertThat("title matches that as it was in revision added", result.latestServer!!.title, `is`("Baking Signal for Science")) - - verify(ApplicationDependencies.getJobManager()).add(isA(RequestGroupV2InfoJob::class.java)) + verify { jobManager.add(ofType(RequestGroupV2InfoJob::class)) } } @Test @@ -406,7 +404,7 @@ class GroupsV2StateProcessorTest { assertThat("local should update to server", result.groupState, `is`(GroupsV2StateProcessor.GroupState.GROUP_UPDATED)) assertThat("revision matches revision approved at", result.latestServer!!.revision, `is`(3)) assertThat("title matches revision approved at", result.latestServer!!.title, `is`("Beam me up")) - verify(ApplicationDependencies.getJobManager()).add(isA(RequestGroupV2InfoJob::class.java)) + verify { jobManager.add(ofType(RequestGroupV2InfoJob::class)) } } @Test @@ -458,7 +456,7 @@ class GroupsV2StateProcessorTest { } } } - doReturn(secondApiCallChangeSet.changeSet!!.toApiResponse()).`when`(groupsV2API).getGroupHistoryPage(any(), eq(100), any(), eq(true)) + every { groupsV2API.getGroupHistoryPage(any(), 100, any(), true) } returns secondApiCallChangeSet.changeSet!!.toApiResponse() val result = processor.updateLocalGroupToRevision(GroupsV2StateProcessor.LATEST, 0, null) @@ -474,10 +472,11 @@ class GroupsV2StateProcessorTest { fun missedMemberAddResolvesWithMultipleRevisionUpdate() { val secondOther = member(ServiceId.from(UUID.randomUUID())) - val updateMessageContextCapture = ArgumentCaptor.forClass(DecryptedGroupV2Context::class.java) profileAndMessageHelper.masterKey = masterKey - doCallRealMethod().`when`(profileAndMessageHelper).insertUpdateMessages(anyLong(), anyOrNull(), any()) - doNothing().`when`(profileAndMessageHelper).storeMessage(updateMessageContextCapture.capture(), anyLong()) + + val updateMessageContextArgs = mutableListOf() + every { profileAndMessageHelper.insertUpdateMessages(any(), any(), any()) } answers { callOriginal() } + every { profileAndMessageHelper.storeMessage(capture(updateMessageContextArgs), any()) } returns Unit given { localState( @@ -513,8 +512,7 @@ class GroupsV2StateProcessorTest { assertThat("local should update to server", result.groupState, `is`(GroupsV2StateProcessor.GroupState.GROUP_UPDATED)) assertThat("members contains second other", result.latestServer!!.membersList, hasItem(secondOther)) - val allUpdateMessageContexts = updateMessageContextCapture.allValues - assertThat("group update messages contains new member add", allUpdateMessageContexts.map { it.change.newMembersList }, hasItem(hasItem(secondOther))) + assertThat("group update messages contains new member add", updateMessageContextArgs.map { it.change.newMembersList }, hasItem(hasItem(secondOther))) } /** @@ -525,10 +523,11 @@ class GroupsV2StateProcessorTest { fun missedMemberAddResolvesWithForcedUpdate() { val secondOther = member(ServiceId.from(UUID.randomUUID())) - val updateMessageContextCapture = ArgumentCaptor.forClass(DecryptedGroupV2Context::class.java) profileAndMessageHelper.masterKey = masterKey - doCallRealMethod().`when`(profileAndMessageHelper).insertUpdateMessages(anyLong(), anyOrNull(), any()) - doNothing().`when`(profileAndMessageHelper).storeMessage(updateMessageContextCapture.capture(), anyLong()) + + val updateMessageContextArgs = mutableListOf() + every { profileAndMessageHelper.insertUpdateMessages(any(), any(), any()) } answers { callOriginal() } + every { profileAndMessageHelper.storeMessage(capture(updateMessageContextArgs), any()) } returns Unit given { localState( @@ -548,12 +547,11 @@ class GroupsV2StateProcessorTest { assertThat("members contains second other", result.latestServer!!.membersList, hasItem(secondOther)) assertThat("title should be updated", result.latestServer!!.title, `is`("Changed")) - val allUpdateMessageContexts = updateMessageContextCapture.allValues - assertThat("group update messages contains new member add", allUpdateMessageContexts.map { it.change.newMembersList }, hasItem(hasItem(secondOther))) + assertThat("group update messages contains new member add", updateMessageContextArgs.map { it.change.newMembersList }, hasItem(hasItem(secondOther))) assertThat( "group update messages contains title change", - allUpdateMessageContexts.map { it.change.newTitle }, + updateMessageContextArgs.map { it.change.newTitle }, hasItem(both(notNullValue()).and(hasProperty("value", `is`("Changed")))) ) } diff --git a/app/src/test/java/org/thoughtcrime/securesms/jobmanager/migrations/SenderKeyDistributionSendJobRecipientMigrationTest.java b/app/src/test/java/org/thoughtcrime/securesms/jobmanager/migrations/SenderKeyDistributionSendJobRecipientMigrationTest.java index 92a05d934c..5d9fb938b4 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/jobmanager/migrations/SenderKeyDistributionSendJobRecipientMigrationTest.java +++ b/app/src/test/java/org/thoughtcrime/securesms/jobmanager/migrations/SenderKeyDistributionSendJobRecipientMigrationTest.java @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.jobmanager.migrations; import org.junit.Test; import org.thoughtcrime.securesms.database.GroupTable; +import org.thoughtcrime.securesms.database.model.GroupRecord; import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.jobmanager.Data; import org.thoughtcrime.securesms.jobmanager.JobMigration; @@ -34,7 +35,7 @@ public class SenderKeyDistributionSendJobRecipientMigrationTest { .putBlobAsString("group_id", GROUP_ID.getDecodedId()) .build()); - GroupTable.GroupRecord mockGroup = mock(GroupTable.GroupRecord.class); + GroupRecord mockGroup = mock(GroupRecord.class); when(mockGroup.getRecipientId()).thenReturn(RecipientId.from(2)); when(mockDatabase.getGroup(GROUP_ID)).thenReturn(Optional.of(mockGroup)); diff --git a/core-util/src/main/java/org/signal/core/util/CursorExtensions.kt b/core-util/src/main/java/org/signal/core/util/CursorExtensions.kt index 54185cb329..0a5b290c7d 100644 --- a/core-util/src/main/java/org/signal/core/util/CursorExtensions.kt +++ b/core-util/src/main/java/org/signal/core/util/CursorExtensions.kt @@ -92,6 +92,16 @@ fun Cursor.readToSingleObject(serializer: Serializer): T? { } } +fun Cursor.readToSingleObject(mapper: (Cursor) -> T): T? { + return use { + if (it.moveToFirst()) { + mapper(it) + } else { + null + } + } +} + @JvmOverloads fun Cursor.readToSingleInt(defaultValue: Int = 0): Int { return use {