From 12785f35c92d5cfd47bfb9595b348fb7e4d6984a Mon Sep 17 00:00:00 2001 From: Michelle Tang Date: Fri, 24 Jul 2026 10:21:01 -0400 Subject: [PATCH] Improve group deletion. Co-authored-by: Cody Henthorne --- .../securesms/backup/v2/BackupRepository.kt | 2 +- .../ConversationSettingsRepository.kt | 2 +- .../PermissionsSettingsRepository.kt | 2 +- .../ShowAdminsBottomSheetDialog.java | 2 +- .../conversation/v2/ConversationRepository.kt | 4 +- .../conversation/v2/ConversationViewModel.kt | 2 +- .../v2/groups/ConversationGroupViewModel.kt | 2 +- .../ConversationListFragment.java | 35 ++-- .../securesms/database/CallLinkTable.kt | 9 + .../securesms/database/CallTable.kt | 14 ++ .../securesms/database/ChatFolderTables.kt | 2 + .../securesms/database/DatabaseTable.java | 8 + .../database/DistributionListTables.kt | 9 + .../securesms/database/DraftTable.kt | 4 + .../securesms/database/GroupReceiptTable.kt | 9 + .../securesms/database/GroupTable.kt | 141 ++++++++++++++- .../securesms/database/MentionTable.kt | 18 ++ .../database/MessageSendLogTables.kt | 9 + .../securesms/database/MessageTable.kt | 22 ++- .../securesms/database/NameCollisionTables.kt | 9 + .../database/NotificationProfileTables.kt | 10 ++ .../securesms/database/PaymentTable.java | 6 + .../PendingPniSignatureMessageTable.kt | 9 + .../database/PendingRetryReceiptTable.java | 15 ++ .../securesms/database/PollTables.kt | 14 ++ .../securesms/database/ReactionTable.kt | 11 +- .../RecipientIdDatabaseReference.java | 2 + .../securesms/database/RecipientTable.kt | 108 +++++++++++ .../securesms/database/SenderKeyTable.kt | 10 ++ .../securesms/database/StorySendTable.kt | 10 ++ .../database/ThreadIdDatabaseReference.java | 1 + .../securesms/database/ThreadTable.kt | 30 ++++ .../securesms/database/model/GroupRecord.kt | 19 +- .../securesms/groups/GroupManager.java | 15 +- .../securesms/groups/GroupManagerV2.java | 94 +++++++--- .../securesms/groups/LiveGroup.java | 13 +- .../securesms/groups/ui/LeaveGroupDialog.java | 1 + .../PendingMemberInvitesRepository.java | 12 +- .../v2/processing/GroupsV2StateProcessor.kt | 16 +- .../jobs/AvatarGroupsV2DownloadJob.java | 13 +- .../securesms/jobs/ForceUpdateGroupV2Job.java | 2 +- .../jobs/ForceUpdateGroupV2WorkerJob.java | 5 + .../jobs/GroupCallUpdateSendJob.java | 8 +- .../jobs/GroupDeletedBackfillWorkerJob.kt | 60 +++++++ .../jobs/GroupV2UpdateSelfProfileKeyJob.java | 7 +- .../securesms/jobs/JobManagerFactories.java | 3 + .../MultiDeviceMessageRequestResponseJob.java | 5 + .../securesms/jobs/NullMessageSendJob.java | 5 + .../securesms/jobs/PushGroupSendJob.java | 8 + .../securesms/jobs/ReactionSendJob.java | 3 +- .../securesms/jobs/RemoteDeleteSendJob.java | 3 +- .../jobs/RequestGroupV2InfoWorkerJob.java | 5 + .../MessageRequestRepository.java | 2 +- .../messages/DataMessageProcessor.kt | 2 +- .../securesms/messages/GroupSendUtil.java | 2 +- .../migrations/ApplicationMigrations.java | 7 +- .../GroupDeletedBackfillMigrationJob.kt | 35 ++++ .../securesms/profiles/AvatarHelper.java | 2 +- .../securesms/recipients/Recipient.kt | 1 + .../securesms/recipients/RecipientUtil.java | 2 +- .../RecipientDialogRepository.java | 2 +- .../bottomsheet/RecipientDialogViewModel.java | 9 +- .../ShareableGroupLinkRepository.java | 28 ++- .../securesms/service/PinnedMessageManager.kt | 8 +- .../securesms/util/GroupUtil.java | 11 +- app/src/main/res/values/strings.xml | 25 ++- .../securesms/database/GroupTableTest.kt | 170 ++++++++++++++++++ .../securesms/database/RecipientTableTest.kt | 26 +++ .../jobs/GroupDeletedBackfillWorkerJobTest.kt | 126 +++++++++++++ .../securesms/testutil/SignalDatabaseRule.kt | 3 + .../securesms/util/GroupUtilTest.kt | 51 ++++++ .../main/java/org/signal/core/util/SqlUtil.kt | 12 ++ 72 files changed, 1227 insertions(+), 125 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/jobs/GroupDeletedBackfillWorkerJob.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/migrations/GroupDeletedBackfillMigrationJob.kt create mode 100644 app/src/test/java/org/thoughtcrime/securesms/jobs/GroupDeletedBackfillWorkerJobTest.kt create mode 100644 app/src/test/java/org/thoughtcrime/securesms/util/GroupUtilTest.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt index 3cf495095b..01f6f89e6c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt @@ -1522,7 +1522,7 @@ object BackupRepository { val jobs = mutableListOf() groups .asSequence() - .filter { it.id.isV2 } + .filter { it.id.isV2 && it.hasV2GroupProperties } .forEach { group -> jobs.add(RequestGroupV2InfoJob(group.id as GroupId.V2)) val avatarKey = group.requireV2GroupProperties().avatarKey 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 b4c316ca7a..f315704107 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 @@ -141,7 +141,7 @@ class ConversationSettingsRepository( SignalExecutors.BOUNDED.execute { val groupRecord: GroupRecord = SignalDatabase.groups.getGroup(groupId).get() consumer( - if (groupRecord.isV2Group) { + if (groupRecord.hasV2GroupProperties) { val decryptedGroup: DecryptedGroup = groupRecord.requireV2GroupProperties().decryptedGroup val pendingMembers: List = decryptedGroup.pendingMembers .map { m -> m.serviceIdBytes } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsRepository.kt index 6d1a04ad85..1aee3d45d7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/permissions/PermissionsSettingsRepository.kt @@ -65,7 +65,7 @@ class PermissionsSettingsRepository( fun hasNonAdminMembersWithLabels(groupId: GroupId): Boolean { val v2GroupId = groupId.v2OrNull() ?: return false - val group = groupTable.getGroup(v2GroupId).orNull() ?: return false + val group = groupTable.getGroup(v2GroupId).filter { it.hasV2GroupProperties }.orNull() ?: return false return group.requireV2GroupProperties().nonAdminMembersWithLabels().isNotEmpty() } 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 47fd426558..8ea190e1dd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ShowAdminsBottomSheetDialog.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ShowAdminsBottomSheetDialog.java @@ -108,7 +108,7 @@ public final class ShowAdminsBottomSheetDialog extends BottomSheetDialogFragment @WorkerThread private static @NonNull List getAdmins(@NonNull Context context, @NonNull GroupId groupId) { GroupRecord groupRecord = SignalDatabase.groups().getGroup(groupId).orElse(null); - if (groupRecord == null) { + if (groupRecord == null || !groupRecord.getHasV2GroupProperties()) { return Collections.emptyList(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt index f237fcc862..ecbafb9938 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt @@ -600,7 +600,7 @@ class ConversationRepository( return Single.fromCallable { val recipients = if (groupRecord == null) { listOf(recipient) - } else if (groupRecord.isV2Group) { + } else if (groupRecord.hasV2GroupProperties) { groupRecord.requireV2GroupProperties().getMemberRecipients(GroupTable.MemberSet.FULL_MEMBERS_EXCLUDING_SELF) } else { emptyList() @@ -651,7 +651,7 @@ class ConversationRepository( } } - if (group != null && group.isV2Group) { + if (group != null && group.hasV2GroupProperties) { val groupId = group.id.requireV2() val duplicateRecipients: List = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(group.recipientId) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index ee827a1efc..207af08fd3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -153,7 +153,7 @@ class ConversationViewModel( val groupMemberServiceIds: Observable> = recipientRepository .groupRecord - .filter { it.isPresent && it.get().isV2Group } + .filter { it.isPresent && it.get().hasV2GroupProperties } .map { it.get().requireV2GroupProperties().getMemberServiceIds() } .distinctUntilChanged() .observeOn(AndroidSchedulers.mainThread()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/groups/ConversationGroupViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/groups/ConversationGroupViewModel.kt index 5f473ef1b6..f4df2d6ebe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/groups/ConversationGroupViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/groups/ConversationGroupViewModel.kt @@ -53,7 +53,7 @@ class ConversationGroupViewModel( init { disposables += _groupRecord.subscribe { groupRecord -> - _groupActiveState.onNext(ConversationGroupActiveState(groupRecord.isActive, groupRecord.isV2Group)) + _groupActiveState.onNext(ConversationGroupActiveState(groupRecord.isActive, groupRecord.hasV2GroupProperties)) _memberLevel.onNext(ConversationGroupMemberLevel(groupRecord.memberLevel(Recipient.self()), groupRecord.isAnnouncementGroup, groupRecord.attributesAccessControl == GroupAccessControl.ALL_MEMBERS)) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java index 993b8b8e58..2040d158f0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -1192,11 +1192,11 @@ public class ConversationListFragment extends MainFragment implements Conversati } @SuppressLint("StaticFieldLeak") - private void handleDelete(@NonNull Collection ids) { + private void handleDelete(@NonNull Collection ids, boolean containsActiveGroup) { if (DeleteSyncEducationDialog.shouldShow()) { lifecycleDisposable.add( DeleteSyncEducationDialog.show(getChildFragmentManager()) - .subscribe(() -> handleDelete(ids)) + .subscribe(() -> handleDelete(ids, containsActiveGroup)) ); return; @@ -1205,18 +1205,24 @@ public class ConversationListFragment extends MainFragment implements Conversati int conversationsCount = ids.size(); MaterialAlertDialogBuilder alert = new MaterialAlertDialogBuilder(requireActivity()); Context context = requireContext(); + boolean isMultiDevice = SignalStore.account().isMultiDevice(); alert.setTitle(context.getResources().getQuantityString(R.plurals.ConversationListFragment_delete_selected_conversations, conversationsCount, conversationsCount)); - if (SignalStore.account().isMultiDevice()) { - alert.setMessage(context.getResources().getQuantityString(R.plurals.ConversationListFragment_this_will_permanently_delete_all_n_selected_conversations_linked_device, - conversationsCount, conversationsCount)); + int messageRes; + if (isMultiDevice && containsActiveGroup) { + messageRes = R.plurals.ConversationListFragment_this_will_permanently_delete_all_n_selected_conversations_linked_device_group; + } else if (isMultiDevice) { + messageRes = R.plurals.ConversationListFragment_this_will_permanently_delete_all_n_selected_conversations_linked_device; + } else if (containsActiveGroup) { + messageRes = R.plurals.ConversationListFragment_this_will_permanently_delete_all_n_selected_conversations_group; } else { - alert.setMessage(context.getResources().getQuantityString(R.plurals.ConversationListFragment_this_will_permanently_delete_all_n_selected_conversations, - conversationsCount, conversationsCount)); + messageRes = R.plurals.ConversationListFragment_this_will_permanently_delete_all_n_selected_conversations; } + alert.setMessage(context.getResources().getQuantityString(messageRes, conversationsCount, conversationsCount)); + alert.setCancelable(true); alert.setPositiveButton(R.string.delete, (dialog, which) -> { @@ -1452,7 +1458,7 @@ public class ConversationListFragment extends MainFragment implements Conversati items.add(new ActionItem(R.drawable.symbol_archive_24, getResources().getString(R.string.ConversationListFragment_archive), () -> handleArchive(id))); } - items.add(new ActionItem(org.signal.core.ui.R.drawable.symbol_trash_24, getResources().getString(R.string.ConversationListFragment_delete), () -> handleDelete(id))); + items.add(new ActionItem(org.signal.core.ui.R.drawable.symbol_trash_24, getResources().getString(R.string.ConversationListFragment_delete), () -> handleDelete(id, conversation.getThreadRecord().getRecipient().resolve().isActiveGroup()))); activeContextMenu = new SignalContextMenu.Builder(view, list) .offsetX(ViewUtil.dpToPx(12)) @@ -1524,11 +1530,12 @@ public class ConversationListFragment extends MainFragment implements Conversati } private void updateMultiSelectState() { - int count = viewModel.currentSelectedConversations().size(); - boolean hasUnread = viewModel.currentSelectedConversations().stream().anyMatch(conversation -> !conversation.getThreadRecord().isRead()); - boolean hasUnpinned = viewModel.currentSelectedConversations().stream().anyMatch(conversation -> !conversation.getThreadRecord().isPinned()); - boolean hasUnmuted = viewModel.currentSelectedConversations().stream().anyMatch(conversation -> !conversation.getThreadRecord().getRecipient().live().get().isMuted()); - boolean canPin = viewModel.getPinnedCount() < RemoteConfig.pinnedChatLimit(); + int count = viewModel.currentSelectedConversations().size(); + boolean hasUnread = viewModel.currentSelectedConversations().stream().anyMatch(conversation -> !conversation.getThreadRecord().isRead()); + boolean hasUnpinned = viewModel.currentSelectedConversations().stream().anyMatch(conversation -> !conversation.getThreadRecord().isPinned()); + boolean hasUnmuted = viewModel.currentSelectedConversations().stream().anyMatch(conversation -> !conversation.getThreadRecord().getRecipient().resolve().isMuted()); + boolean containsGroup = viewModel.currentSelectedConversations().stream().anyMatch(conversation -> conversation.getThreadRecord().getRecipient().resolve().isActiveGroup()); + boolean canPin = viewModel.getPinnedCount() < RemoteConfig.pinnedChatLimit(); if (mainToolbarViewModel.isInActionMode()) { mainToolbarViewModel.setActionModeCount(count); @@ -1559,7 +1566,7 @@ public class ConversationListFragment extends MainFragment implements Conversati items.add(new ActionItem(R.drawable.symbol_archive_24, getResources().getString(R.string.ConversationListFragment_archive), () -> handleArchive(selectionIds))); } - items.add(new ActionItem(org.signal.core.ui.R.drawable.symbol_trash_24, getResources().getString(R.string.ConversationListFragment_delete), () -> handleDelete(selectionIds))); + items.add(new ActionItem(org.signal.core.ui.R.drawable.symbol_trash_24, getResources().getString(R.string.ConversationListFragment_delete), () -> handleDelete(selectionIds, containsGroup))); if (hasUnmuted) { items.add(new ActionItem(R.drawable.symbol_bell_slash_24, getResources().getString(R.string.ConversationListFragment_mute), () -> handleMute(viewModel.currentSelectedConversations()))); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/CallLinkTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/CallLinkTable.kt index 13ec864aae..820bb6329d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/CallLinkTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/CallLinkTable.kt @@ -532,4 +532,13 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database Log.d(TAG, "Remapped $fromId to $toId. count: $count") } + + override fun onDeletedRecipient(recipientId: RecipientId) { + val deleted = writableDatabase + .delete(TABLE_NAME) + .where("$RECIPIENT_ID = ?", recipientId) + .run() + + Log.d(TAG, "Deleted recipient: $deleted") + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/CallTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/CallTable.kt index 98ec127de1..d8ee10d25f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/CallTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/CallTable.kt @@ -1387,6 +1387,20 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl Log.d(TAG, "Remapped $fromId to $toId. peerCount: $peerCount, ringerCount: $ringerCount") } + override fun onDeletedRecipient(recipientId: RecipientId) { + val deletedPeer = writableDatabase + .delete(TABLE_NAME) + .where("$PEER = ?", recipientId) + .run() + + val deletedRinger = writableDatabase + .delete(TABLE_NAME) + .where("$RINGER = ?", recipientId) + .run() + + Log.d(TAG, "Deleted recipient: $deletedPeer $deletedRinger") + } + /** * @param isGroupCallActive - Whether the group call currently contains users. Only valid for group calls. * @param didLocalUserJoin - Determines whether the local user joined this call. Only valid for group calls. diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ChatFolderTables.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ChatFolderTables.kt index 60fe3fc40e..376da71c6b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ChatFolderTables.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ChatFolderTables.kt @@ -144,6 +144,8 @@ class ChatFolderTables(context: Context?, databaseHelper: SignalDatabase?) : Dat .run() } + override fun onDeletedGroupThread(threadId: Long) = Unit // No-op bc thread id is foreign key + /** * Returns a single chat folder that corresponds to that query. * Assumes query will only match to one chat folder. diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseTable.java b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseTable.java index 2ddc7c1d04..3d61081e35 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseTable.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseTable.java @@ -18,6 +18,8 @@ package org.thoughtcrime.securesms.database; import android.content.Context; +import androidx.annotation.VisibleForTesting; + import org.thoughtcrime.securesms.dependencies.AppDependencies; import java.util.HashSet; @@ -47,6 +49,12 @@ public abstract class DatabaseTable { } } + @VisibleForTesting + public static void clearTableReferencesForTests() { + recipientIdDatabaseTables.clear(); + threadIdDatabaseTables.clear(); + } + protected void notifyConversationListeners(Set threadIds) { AppDependencies.getDatabaseObserver().notifyConversationListeners(threadIds); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DistributionListTables.kt b/app/src/main/java/org/thoughtcrime/securesms/database/DistributionListTables.kt index ef452bfc2b..411747e60b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/DistributionListTables.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DistributionListTables.kt @@ -554,6 +554,15 @@ class DistributionListTables constructor(context: Context?, databaseHelper: Sign Log.d(TAG, "Remapped $fromId to $toId.") } + override fun onDeletedRecipient(recipientId: RecipientId) { + val deleted = writableDatabase + .delete(MembershipTable.TABLE_NAME) + .where("${MembershipTable.RECIPIENT_ID} = ?", recipientId) + .run() + + Log.d(TAG, "Deleted recipient: $deleted") + } + fun deleteList(distributionListId: DistributionListId, deletionTimestamp: Long = System.currentTimeMillis()) { writableDatabase.update( ListTable.TABLE_NAME, diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DraftTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/DraftTable.kt index 167e9361e5..e6474d5f96 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/DraftTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DraftTable.kt @@ -113,6 +113,10 @@ class DraftTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT .run() } + override fun onDeletedGroupThread(threadId: Long) { + clearDrafts(threadId) + } + private fun List.asDrafts(): Drafts { return Drafts(this) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupReceiptTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/GroupReceiptTable.kt index 063949641f..a30a1cd1fa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupReceiptTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupReceiptTable.kt @@ -194,6 +194,15 @@ class GroupReceiptTable(context: Context?, databaseHelper: SignalDatabase?) : Da Log.d(TAG, "Remapped $fromId to $toId. count: $count") } + override fun onDeletedRecipient(recipientId: RecipientId) { + val deleted = writableDatabase + .delete(TABLE_NAME) + .where("$RECIPIENT_ID = ?", recipientId) + .run() + + Log.d(TAG, "Deleted recipient: $deleted") + } + private fun Cursor.toGroupReceiptInfo(): GroupReceiptInfo { return GroupReceiptInfo( recipientId = RecipientId.from(this.requireLong(RECIPIENT_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 index 3542122030..f4707f1111 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.kt @@ -4,6 +4,7 @@ import android.content.ContentValues import android.content.Context import android.database.Cursor import android.text.TextUtils +import androidx.annotation.VisibleForTesting import androidx.annotation.WorkerThread import androidx.core.content.contentValuesOf import okio.ByteString @@ -60,6 +61,7 @@ import org.thoughtcrime.securesms.groups.memberlabel.MemberLabel import org.thoughtcrime.securesms.groups.v2.processing.GroupsV2StateProcessor import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.profiles.AvatarHelper import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil @@ -565,6 +567,7 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : fun getGroupMemberIds(groupId: GroupId, memberSet: MemberSet): List { return if (groupId.isV2) { getGroup(groupId) + .filter { it.hasV2GroupProperties } .map { it.requireV2GroupProperties().getMemberRecipientIds(memberSet) } .orElse(emptyList()) } else { @@ -580,6 +583,7 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : fun getGroupMembers(groupId: GroupId, memberSet: MemberSet): List { return if (groupId.isV2) { getGroup(groupId) + .filter { it.hasV2GroupProperties } .map { it.requireV2GroupProperties().getMemberRecipients(memberSet) } .orElse(emptyList()) } else { @@ -600,7 +604,7 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : fun getGroupInviter(groupId: GroupId): Recipient? { val groupRecord: Optional = getGroup(groupId) - if (groupRecord.isPresent && groupRecord.get().isV2Group) { + if (groupRecord.isPresent && groupRecord.get().hasV2GroupProperties) { val pendingMembers: List = groupRecord.get().requireV2GroupProperties().decryptedGroup.pendingMembers val invitedByAci: ByteString? = DecryptedGroupUtil.findPendingByServiceId(pendingMembers, Recipient.self().requireAci()) .or { DecryptedGroupUtil.findPendingByServiceId(pendingMembers, Recipient.self().requirePni()) } @@ -850,7 +854,7 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : contentValues.put(GROUP_SEND_ENDORSEMENTS_EXPIRATION, receivedGroupSendEndorsements.expirationMs) } - if (existingGroup.isPresent && existingGroup.get().unmigratedV1Members.isNotEmpty() && existingGroup.get().isV2Group) { + if (existingGroup.isPresent && existingGroup.get().unmigratedV1Members.isNotEmpty() && existingGroup.get().hasV2GroupProperties) { val unmigratedV1Members: MutableSet = existingGroup.get().unmigratedV1Members.toMutableSet() val change = GroupChangeReconstruct.reconstructGroupChange(existingGroup.get().requireV2GroupProperties().decryptedGroup, decryptedGroup) @@ -873,14 +877,18 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : val groupMembers = getV2GroupMembers(decryptedGroup, true) var groupSendEndorsementRecords: GroupSendEndorsementRecords? = receivedGroupSendEndorsements?.toGroupSendEndorsementRecords() ?: getGroupSendEndorsements(groupId) - val addedMembers: Collection = if (existingGroup.isPresent && existingGroup.get().isV2Group) { + val addedMembers: Collection = if (existingGroup.isPresent && existingGroup.get().hasV2GroupProperties) { val change = GroupChangeReconstruct.reconstructGroupChange(existingGroup.get().requireV2GroupProperties().decryptedGroup, decryptedGroup) val removed: List = DecryptedGroupUtil.removedMembersServiceIdList(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) + val distributionId = existingGroup.get().distributionId + if (distributionId != null) { + Log.i(TAG, removed.size.toString() + " members were removed from group " + groupId + ". Rotating the DistributionId " + distributionId) + SenderKeyUtil.rotateOurKey(distributionId) + } else { + Log.i(TAG, removed.size.toString() + " members were removed from group " + groupId + " but there is no DistributionId to rotate.") + } } change.promotePendingPniAciMembers.forEach { member -> @@ -989,6 +997,108 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : return record.isPresent && record.get().isActive } + fun clearGroupIfLeftAndDeleted(groupId: GroupId) { + clearGroupIfLeftAndDeleted(getGroup(groupId).orNull()) + } + + fun clearGroupIfLeftAndDeleted(recipientId: RecipientId) { + clearGroupIfLeftAndDeleted(getGroup(recipientId).orNull()) + } + + private fun clearGroupIfLeftAndDeleted(record: GroupRecord?) { + if (record == null) { + return + } + + if (record.isActive) { + Log.i(TAG, "Not clearing since user is still active in group") + return + } + + if (SignalDatabase.threads.hasActiveThread(record.recipientId)) { + Log.i(TAG, "Not clearing since thread is still active") + return + } + + Log.i(TAG, "Group ${record.id} has been both left and had its thread deleted. Clearing all group data.") + val keepGroupIdentifier = SignalStore.account.isMultiDevice || recipients.isBlocked(record.recipientId) + var clearRecipientCache: Boolean = false + writableDatabase.withinTransaction { db -> + db + .delete(MembershipTable.TABLE_NAME) + .where("${MembershipTable.GROUP_ID} = ?", record.id) + .run() + + record.distributionId?.let { distributionId -> + SignalDatabase.senderKeys.deleteAllFor(distributionId) + SignalDatabase.senderKeyShared.deleteAllFor(distributionId) + } + + SignalDatabase.threads.deleteThread(record.recipientId) + clearRecipientCache = recipients.clearGroupRecipient(record.recipientId, keepGroupIdentifier) + clearGroupRecipient(record.id, keepGroupIdentifier) + } + + if (clearRecipientCache) { + AppDependencies.recipientCache.clear() + } + + if (!keepGroupIdentifier) { + RecipientId.clearCache() + } + + AvatarHelper.delete(context, record.recipientId) + } + + /** + * Clears all data for a group. If [keepIdentifier], we save the [ID], [RECIPIENT_ID], [GROUP_ID], and [V2_MASTER_KEY]. + * Which is the minimum amount of data required to keep for storage service/blocked lists. + */ + private fun clearGroupRecipient(id: GroupId, keepIdentifier: Boolean) { + val cleared = if (keepIdentifier) { + writableDatabase + .update(TABLE_NAME) + .values(buildClearedGroupValues()) + .where("$GROUP_ID = ?", id) + .run() + } else { + writableDatabase + .delete(TABLE_NAME) + .where("$GROUP_ID = ?", id) + .run() + } + + Log.i(TAG, "Clearing group recipient. Keeping id: $keepIdentifier, cleared: $cleared") + } + + /** + * The values written when clearing a group while keeping its identifier. Every column must appear here except the ones + * intentionally preserved. See [GroupTableTest] which enforces this. + */ + @VisibleForTesting + fun buildClearedGroupValues(): ContentValues { + return contentValuesOf( + TITLE to null, + AVATAR_ID to 0, + AVATAR_KEY to null, + AVATAR_CONTENT_TYPE to null, + AVATAR_DIGEST to null, + TIMESTAMP to 0, + IS_MEMBER to 0, + TERMINATED_BY to 0, + MMS to 0, + V2_REVISION to null, + V2_DECRYPTED_GROUP to null, + EXPECTED_V2_ID to null, + UNMIGRATED_V1_MEMBERS to null, + DISTRIBUTION_ID to null, + SHOW_AS_STORY_STATE to ShowAsStoryState.IF_ACTIVE.code, + LAST_FORCE_UPDATE_TIMESTAMP to 0, + GROUP_SEND_ENDORSEMENTS_EXPIRATION to 0, + V2_VERIFIED_NAME_HASH to null + ) + } + fun isMember(groupId: GroupId): Boolean { val record = getGroup(groupId) return record.isPresent && record.get().isMember @@ -1073,7 +1183,7 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : } fun getGroupSendFullToken(groupId: GroupId.V2, recipientId: RecipientId): GroupSendFullToken? { - val groupRecord = SignalDatabase.groups.getGroup(groupId).orElse(null) ?: return null + val groupRecord = SignalDatabase.groups.getGroup(groupId).filter { it.hasV2GroupProperties }.orElse(null) ?: return null val endorsement = SignalDatabase.groups.getGroupSendEndorsement(groupId, recipientId) ?: return null val groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupRecord.requireV2GroupProperties().groupMasterKey) @@ -1166,12 +1276,27 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : .run() for (group in getGroupsContainingMember(fromId, pushOnly = false, includeInactive = true)) { - if (group.isV2Group) { + if (group.hasV2GroupProperties) { removeUnmigratedV1Members(group.id.requireV2(), listOf(fromId)) } } } + override fun onDeletedRecipient(recipientId: RecipientId) { + val deletedMembership = writableDatabase + .delete(MembershipTable.TABLE_NAME) + .where("${MembershipTable.RECIPIENT_ID} = ?", recipientId) + .run() + + val clearedTerminatedBy = writableDatabase + .update(TABLE_NAME) + .values(TERMINATED_BY to -1) + .where("$TERMINATED_BY = ?", recipientId) + .run() + + Log.d(TAG, "Deleted recipient. membership: $deletedMembership, terminatedBy cleared: $clearedTerminatedBy") + } + class Reader(val cursor: Cursor?) : Closeable, ContactSearchIterator { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MentionTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MentionTable.kt index be5432d82f..f89e3a975d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MentionTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MentionTable.kt @@ -177,6 +177,15 @@ class MentionTable(context: Context, databaseHelper: SignalDatabase) : DatabaseT Log.d(TAG, "Remapped $fromId to $toId. count: $count") } + override fun onDeletedRecipient(recipientId: RecipientId) { + val deleted = writableDatabase + .delete("$TABLE_NAME INDEXED BY $RECIPIENT_ID_INDEX") + .where("$RECIPIENT_ID = ?", recipientId) + .run() + + Log.d(TAG, "Deleted recipient: $deleted") + } + override fun remapThread(fromId: Long, toId: Long) { writableDatabase .update("$TABLE_NAME INDEXED BY $RECIPIENT_ID_INDEX") @@ -184,4 +193,13 @@ class MentionTable(context: Context, databaseHelper: SignalDatabase) : DatabaseT .where("$THREAD_ID = $fromId") .run() } + + override fun onDeletedGroupThread(threadId: Long) { + val deleted = writableDatabase + .delete("$TABLE_NAME INDEXED BY $RECIPIENT_ID_INDEX") + .where("$THREAD_ID = ?", threadId) + .run() + + Log.d(TAG, "Deleted mentions for thread: $deleted") + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageSendLogTables.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MessageSendLogTables.kt index 7b04a03754..89b566ae88 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageSendLogTables.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageSendLogTables.kt @@ -430,5 +430,14 @@ class MessageSendLogTables constructor(context: Context?, databaseHelper: Signal Log.d(TAG, "Remapped $fromId to $toId. count: $count") } + override fun onDeletedRecipient(recipientId: RecipientId) { + val deleted = writableDatabase + .delete(MslRecipientTable.TABLE_NAME) + .where("${MslRecipientTable.RECIPIENT_ID} = ?", recipientId) + .run() + + Log.d(TAG, "Deleted recipient: $deleted") + } + private data class RecipientDevice(val recipientId: RecipientId, val devices: List) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt index 6b0dd2aad7..2451f73988 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt @@ -1287,7 +1287,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat TrimThreadJob.enqueueAsync(threadId) } - groupRecords.filter { it.isV2Group }.forEach { + groupRecords.filter { it.hasV2GroupProperties }.forEach { SignalDatabase.nameCollisions.handleGroupNameCollisions(it.id.requireV2(), setOf(recipient.id)) } } @@ -6087,6 +6087,26 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat } } + override fun onDeletedRecipient(recipientId: RecipientId) { + val deleted = writableDatabase + .delete(TABLE_NAME) + .where( + "$FROM_RECIPIENT_ID = ? OR $TO_RECIPIENT_ID = ? OR $QUOTE_AUTHOR = ? OR $DELETED_BY = ?", + recipientId, + recipientId, + recipientId, + recipientId + ) + .run() + + Log.d(TAG, "Deleted recipient: $deleted") + } + + /** To get here a thread already needs to be empty, so this is effectively a no-op */ + override fun onDeletedGroupThread(threadId: Long) { + deleteMessagesInThread(listOf(threadId)) + } + /** * Returns the next ID that would be generated if an insert was done on this table. * You should *not* use this for actually generating an ID to use. That will happen automatically! diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/NameCollisionTables.kt b/app/src/main/java/org/thoughtcrime/securesms/database/NameCollisionTables.kt index 208ea94cf4..c12b3b1261 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/NameCollisionTables.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/NameCollisionTables.kt @@ -287,6 +287,15 @@ class NameCollisionTables( Log.d(TAG, "Remapped $fromId to $toId") } + override fun onDeletedRecipient(recipientId: RecipientId) { + val deleted = writableDatabase + .delete(NameCollisionMembershipTable.TABLE_NAME) + .where("${NameCollisionMembershipTable.RECIPIENT_ID} = ?", recipientId) + .run() + + Log.d(TAG, "Deleted recipient: $deleted") + } + private fun handleNameCollisions( threadRecipientId: RecipientId, getCollisionRecipients: () -> Set diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/NotificationProfileTables.kt b/app/src/main/java/org/thoughtcrime/securesms/database/NotificationProfileTables.kt index 8c689b860d..7be7fdd87d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/NotificationProfileTables.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/NotificationProfileTables.kt @@ -9,6 +9,7 @@ import androidx.core.content.contentValuesOf import org.signal.core.util.Base64 import org.signal.core.util.SqlUtil import org.signal.core.util.UuidUtil +import org.signal.core.util.delete import org.signal.core.util.exists import org.signal.core.util.hasUnknownFields import org.signal.core.util.insertInto @@ -531,6 +532,15 @@ class NotificationProfileTables(context: Context, databaseHelper: SignalDatabase Log.d(TAG, "Remapped $fromId to $toId. count: $count") } + override fun onDeletedRecipient(recipientId: RecipientId) { + val deleted = writableDatabase + .delete(NotificationProfileAllowedMembersTable.TABLE_NAME) + .where("${NotificationProfileAllowedMembersTable.RECIPIENT_ID} = ?", recipientId) + .run() + + Log.d(TAG, "Deleted recipient: $deleted") + } + private fun getProfile(cursor: Cursor): NotificationProfile { val profileId: Long = cursor.requireLong(NotificationProfileTable.ID) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/PaymentTable.java b/app/src/main/java/org/thoughtcrime/securesms/database/PaymentTable.java index b6c4bc9fe3..b413e1f76b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/PaymentTable.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/PaymentTable.java @@ -483,6 +483,12 @@ public final class PaymentTable extends DatabaseTable implements RecipientIdData Log.d(TAG, "Remapped " + fromId + " to " + toId + ". count: " + count); } + @Override + public void onDeletedRecipient(@NonNull RecipientId recipientId) { + int count = getWritableDatabase().delete(TABLE_NAME, RECIPIENT_ID + " = ?", new String[] { recipientId.serialize() }); + Log.d(TAG, "Deleted recipient: " + count); + } + public boolean markPaymentSubmitted(@NonNull UUID uuid, @NonNull byte[] transaction, @NonNull byte[] receipt, diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/PendingPniSignatureMessageTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/PendingPniSignatureMessageTable.kt index a897492de1..fda9c93f93 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/PendingPniSignatureMessageTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/PendingPniSignatureMessageTable.kt @@ -130,4 +130,13 @@ class PendingPniSignatureMessageTable(context: Context, databaseHelper: SignalDa Log.d(TAG, "Remapped $fromId to $toId. count: $count") } + + override fun onDeletedRecipient(recipientId: RecipientId) { + val deleted = writableDatabase + .delete(TABLE_NAME) + .where("$RECIPIENT_ID = ?", recipientId) + .run() + + Log.d(TAG, "Deleted recipient: $deleted") + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/PendingRetryReceiptTable.java b/app/src/main/java/org/thoughtcrime/securesms/database/PendingRetryReceiptTable.java index 0c8348d838..bf0b5c27cd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/PendingRetryReceiptTable.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/PendingRetryReceiptTable.java @@ -97,6 +97,14 @@ public final class PendingRetryReceiptTable extends DatabaseTable implements Rec Log.d(TAG, "Remapped " + fromId + " to " + toId + ". count: " + count); } + @Override + public void onDeletedRecipient(@NonNull RecipientId recipientId) { + int count = databaseHelper.getSignalWritableDatabase().delete(TABLE_NAME, AUTHOR + " = ?", SqlUtil.buildArgs(recipientId.serialize())); + AppDependencies.getPendingRetryReceiptCache().clear(); + + Log.d(TAG, "Deleted recipient: " + count); + } + @Override public void remapThread(long fromId, long toId) { ContentValues values = new ContentValues(); @@ -105,4 +113,11 @@ public final class PendingRetryReceiptTable extends DatabaseTable implements Rec AppDependencies.getPendingRetryReceiptCache().clear(); } + + @Override + public void onDeletedGroupThread(long threadId) { + int count = databaseHelper.getSignalWritableDatabase().delete(TABLE_NAME, THREAD_ID + " = ?", SqlUtil.buildArgs(threadId)); + AppDependencies.getPendingRetryReceiptCache().clear(); + Log.d(TAG, "Deleted pending retry receipts for thread: " + count); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/PollTables.kt b/app/src/main/java/org/thoughtcrime/securesms/database/PollTables.kt index e261c294f8..92a46aae68 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/PollTables.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/PollTables.kt @@ -172,6 +172,20 @@ class PollTables(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT Log.d(TAG, "Remapped $fromId to $toId. count from polls: $countFromPoll from poll votes: $countFromVotes") } + override fun onDeletedRecipient(recipientId: RecipientId) { + val deletedFromPoll = writableDatabase + .delete(PollTable.TABLE_NAME) + .where("${PollTable.AUTHOR_ID} = ?", recipientId) + .run() + + val deletedFromVotes = writableDatabase + .delete(PollVoteTable.TABLE_NAME) + .where("${PollVoteTable.VOTER_ID} = ?", recipientId) + .run() + + Log.d(TAG, "Deleted recipient: $deletedFromPoll $deletedFromVotes") + } + /** * Inserts a newly created poll with its options. Returns the newly created row id */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ReactionTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ReactionTable.kt index 10231f30bf..0433f67338 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ReactionTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ReactionTable.kt @@ -161,7 +161,7 @@ class ReactionTable(context: Context, databaseHelper: SignalDatabase) : Database } } - private fun hasReactions(messageId: MessageId): Boolean { + fun hasReactions(messageId: MessageId): Boolean { val query = "$MESSAGE_ID = ?" val args = SqlUtil.buildArgs(messageId.id) @@ -180,6 +180,15 @@ class ReactionTable(context: Context, databaseHelper: SignalDatabase) : Database Log.d(TAG, "Remapped $fromId to $toId. count: $count") } + override fun onDeletedRecipient(recipientId: RecipientId) { + val deleted = writableDatabase + .delete(TABLE_NAME) + .where("$AUTHOR_ID = ?", recipientId) + .run() + + Log.d(TAG, "Deleted recipient: $deleted") + } + fun deleteAbandonedReactions() { writableDatabase .delete(TABLE_NAME) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientIdDatabaseReference.java b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientIdDatabaseReference.java index c7569f9778..4643f5b783 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientIdDatabaseReference.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientIdDatabaseReference.java @@ -10,4 +10,6 @@ import org.thoughtcrime.securesms.recipients.RecipientId; */ interface RecipientIdDatabaseReference { void remapRecipient(@NonNull RecipientId fromId, @NonNull RecipientId toId); + /** Called when a recipient is deleted or blanked (which does not trigger FK) */ + void onDeletedRecipient(@NonNull RecipientId recipientId); } 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 12fc08ef39..d8d195dbe2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt @@ -3505,6 +3505,16 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da .readToSingleBoolean() } + /** True if the recipient exists and is blocked, otherwise false. */ + fun isBlocked(id: RecipientId): Boolean { + return readableDatabase + .select(BLOCKED) + .from(TABLE_NAME) + .where("$ID = ?", id) + .run() + .readToSingleBoolean() + } + /** All e164's that are eligible for having a signal link added to their system contact entry. */ fun getE164sForSystemContactLinks(): Set { return readableDatabase @@ -4494,6 +4504,104 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da } } + /** + * Blanks out every column for a group's recipient row except [ID], [GROUP_ID], [TYPE], [BLOCKED], and [STORAGE_SERVICE_ID]. + */ + fun clearGroupRecipient(recipientId: RecipientId, keepIdentifier: Boolean): Boolean { + val cleared = if (keepIdentifier) { + writableDatabase + .update(TABLE_NAME) + .values(buildClearedGroupRecipientValues()) + .where("$ID = ?", recipientId) + .run() + } else { + writableDatabase + .delete(TABLE_NAME) + .where("$ID = ?", recipientId) + .run() + } + + for (table in recipientIdDatabaseTables) { + table.onDeletedRecipient(recipientId) + } + + Log.i(TAG, "Cleared group recipient data for $recipientId, cleared: $cleared") + + return cleared > 0 + } + + /** + * The values written when clearing a group recipient while keeping its identifier. Every column must appear here except the + * ones intentionally preserved. See [RecipientTableTest] which enforces this. + */ + @VisibleForTesting + fun buildClearedGroupRecipientValues(): ContentValues { + return contentValuesOf( + E164 to null, + ACI_COLUMN to null, + PNI_COLUMN to null, + USERNAME to null, + EMAIL to null, + DISTRIBUTION_LIST_ID to null, + CALL_LINK_ROOM_ID to null, + REGISTERED to RegisteredState.UNKNOWN.id, + UNREGISTERED_TIMESTAMP to 0, + HIDDEN to 0, + PROFILE_KEY to null, + EXPIRING_PROFILE_KEY_CREDENTIAL to null, + PROFILE_SHARING to 0, + PROFILE_GIVEN_NAME to null, + PROFILE_FAMILY_NAME to null, + PROFILE_JOINED_NAME to null, + PROFILE_AVATAR to null, + LAST_PROFILE_FETCH to 0, + SYSTEM_GIVEN_NAME to null, + SYSTEM_FAMILY_NAME to null, + SYSTEM_JOINED_NAME to null, + SYSTEM_NICKNAME to null, + SYSTEM_PHOTO_URI to null, + SYSTEM_PHONE_LABEL to null, + SYSTEM_PHONE_TYPE to -1, + SYSTEM_CONTACT_URI to null, + SYSTEM_INFO_PENDING to 0, + NOTIFICATION_CHANNEL to null, + MESSAGE_RINGTONE to null, + MESSAGE_VIBRATE to VibrateState.DEFAULT.id, + CALL_RINGTONE to null, + CALL_VIBRATE to VibrateState.DEFAULT.id, + MUTE_UNTIL to 0, + MESSAGE_EXPIRATION_TIME to 0, + MESSAGE_EXPIRATION_TIME_VERSION to 1, + SEALED_SENDER_MODE to 0, + STORAGE_SERVICE_PROTO to null, + MENTION_SETTING to NotificationSetting.ALWAYS_NOTIFY.id, + CALL_NOTIFICATION_SETTING to NotificationSetting.ALWAYS_NOTIFY.id, + REPLY_NOTIFICATION_SETTING to NotificationSetting.ALWAYS_NOTIFY.id, + CAPABILITIES to 0, + LAST_SESSION_RESET to null, + WALLPAPER to null, + WALLPAPER_URI to null, + ABOUT to null, + ABOUT_EMOJI to null, + EXTRAS to null, + GROUPS_IN_COMMON to 0, + AVATAR_COLOR to null, + CHAT_COLORS to null, + CUSTOM_CHAT_COLORS_ID to 0, + BADGES to null, + NEEDS_PNI_SIGNATURE to 0, + REPORTING_TOKEN to null, + PHONE_NUMBER_SHARING to PhoneNumberSharingState.UNKNOWN.id, + PHONE_NUMBER_DISCOVERABLE to PhoneNumberDiscoverableState.UNKNOWN.id, + PNI_SIGNATURE_VERIFIED to 0, + NICKNAME_GIVEN_NAME to null, + NICKNAME_FAMILY_NAME to null, + NICKNAME_JOINED_NAME to null, + NOTE to null, + KEY_TRANSPARENCY_DATA to null + ) + } + /** * Should be called immediately after we create a recipient for self. * This clears up any placeholders we put in the database for the local user, which is typically only done in database migrations. diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SenderKeyTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/SenderKeyTable.kt index 19a6d6a9f4..6ff105e938 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SenderKeyTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SenderKeyTable.kt @@ -108,6 +108,16 @@ class SenderKeyTable internal constructor(context: Context?, databaseHelper: Sig .run() } + /** + * Removes all sender key session state for every address/device for the provided distributionId. + */ + fun deleteAllFor(distributionId: DistributionId) { + writableDatabase + .delete(TABLE_NAME) + .where("$DISTRIBUTION_ID = ?", distributionId) + .run() + } + /** * Get metadata for all sender keys created by the local user. Used for debugging. */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/StorySendTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/StorySendTable.kt index 508cfb52d1..ad11276e4e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/StorySendTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/StorySendTable.kt @@ -5,6 +5,7 @@ import android.content.Context import androidx.core.content.contentValuesOf import org.signal.core.util.CursorUtil import org.signal.core.util.SqlUtil +import org.signal.core.util.delete import org.signal.core.util.logging.Log import org.signal.core.util.readToList import org.signal.core.util.requireLong @@ -223,6 +224,15 @@ class StorySendTable(context: Context, databaseHelper: SignalDatabase) : Databas Log.d(TAG, "Remapped $fromId to $toId. count: $count") } + override fun onDeletedRecipient(recipientId: RecipientId) { + val deleted = writableDatabase + .delete(TABLE_NAME) + .where("$RECIPIENT_ID = ?", recipientId) + .run() + + Log.d(TAG, "Deleted recipient: $deleted") + } + /** * Gets the manifest for a given story, or null if the story should NOT be the one reporting the manifest. */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadIdDatabaseReference.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadIdDatabaseReference.java index fe72fcbe5e..683d45d7ae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadIdDatabaseReference.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadIdDatabaseReference.java @@ -6,4 +6,5 @@ package org.thoughtcrime.securesms.database; */ interface ThreadIdDatabaseReference { void remapThread(long fromId, long toId); + void onDeletedGroupThread(long threadId); } 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 04b49acdbf..366af18638 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt @@ -46,6 +46,7 @@ import org.thoughtcrime.securesms.database.MessageTable.MarkedMessageInfo import org.thoughtcrime.securesms.database.SignalDatabase.Companion.attachments import org.thoughtcrime.securesms.database.SignalDatabase.Companion.drafts import org.thoughtcrime.securesms.database.SignalDatabase.Companion.groupReceipts +import org.thoughtcrime.securesms.database.SignalDatabase.Companion.groups import org.thoughtcrime.securesms.database.SignalDatabase.Companion.mentions import org.thoughtcrime.securesms.database.SignalDatabase.Companion.messageLog import org.thoughtcrime.securesms.database.SignalDatabase.Companion.messages @@ -62,6 +63,7 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.groups.BadGroupIdException import org.thoughtcrime.securesms.groups.GroupId import org.thoughtcrime.securesms.jobs.DeleteAbandonedAttachmentsJob +import org.thoughtcrime.securesms.jobs.GroupDeletedBackfillWorkerJob import org.thoughtcrime.securesms.jobs.MultiDeviceDeleteSyncJob import org.thoughtcrime.securesms.jobs.OptimizeMessageSearchIndexJob import org.thoughtcrime.securesms.keyvalue.SignalStore @@ -1383,6 +1385,10 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa MultiDeviceDeleteSyncJob.enqueueThreadDeletes(addressableMessages, isFullDelete = true) } + for (recipientId in recipientIds) { + groups.clearGroupIfLeftAndDeleted(recipientId) + } + notifyConversationListListeners() notifyConversationListeners(selectedConversations) notifyStickerListeners() @@ -1408,6 +1414,8 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa } } + AppDependencies.jobManager.add(GroupDeletedBackfillWorkerJob()) + notifyConversationListListeners() ConversationUtil.clearAllShortcuts(context) } @@ -2310,6 +2318,28 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa return Reader(cursor) } + fun deleteThread(recipientId: RecipientId) { + val threadId = getThreadIdIfExistsFor(recipientId) + if (threadId == -1L) { + return + } + + val deleted = writableDatabase + .delete(TABLE_NAME) + .where("$RECIPIENT_ID = ?", recipientId) + .run() + + synchronized(threadIdCache) { + threadIdCache.remove(recipientId) + } + + for (table in threadIdDatabaseTables) { + table.onDeletedGroupThread(threadId) + } + + Log.d(TAG, "Deleted thread: $deleted") + } + private fun ChatFolderRecord.toQuery(): String { if (this.id == -1L || this.folderType == ChatFolderRecord.FolderType.ALL) { return "" 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 index f67a0857b0..8cae3236be 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupRecord.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupRecord.kt @@ -82,6 +82,9 @@ class GroupRecord( get() = !isMms && !isV2Group val isV2Group: Boolean + get() = id.isV2 + + val hasV2GroupProperties: Boolean get() = v2GroupProperties != null @get:WorkerThread @@ -98,7 +101,7 @@ class GroupRecord( /** Who is allowed to add to the membership of this group. */ val membershipAdditionAccessControl: GroupAccessControl get() { - return if (isV2Group) { + return if (hasV2GroupProperties) { if ((requireV2GroupProperties().decryptedGroup.accessControl ?: AccessControl()).members == AccessControl.AccessRequired.MEMBER) { GroupAccessControl.ALL_MEMBERS } else { @@ -116,7 +119,7 @@ class GroupRecord( /** Who is allowed to modify the attributes of this group, name/avatar/timer etc. */ val attributesAccessControl: GroupAccessControl get() { - return if (isV2Group) { + return if (hasV2GroupProperties) { if ((requireV2GroupProperties().decryptedGroup.accessControl ?: AccessControl()).attributes == AccessControl.AccessRequired.MEMBER) { GroupAccessControl.ALL_MEMBERS } else { @@ -136,7 +139,7 @@ class GroupRecord( */ val memberLabelAccessControl: GroupAccessControl get() { - if (!isV2Group) { + if (!hasV2GroupProperties) { return GroupAccessControl.ALL_MEMBERS } @@ -151,7 +154,7 @@ class GroupRecord( } val actionableRequestingMembersCount: Int by lazy { - if (isV2Group && memberLevel(Recipient.self()) == GroupTable.MemberLevel.ADMINISTRATOR) { + if (hasV2GroupProperties && memberLevel(Recipient.self()) == GroupTable.MemberLevel.ADMINISTRATOR) { requireV2GroupProperties() .decryptedGroup .requestingMembers.size @@ -161,7 +164,7 @@ class GroupRecord( } val gv1MigrationSuggestions: List by lazy { - if (!isActive || !isV2Group || isPendingMember(Recipient.self())) { + if (!isActive || !hasV2GroupProperties || isPendingMember(Recipient.self())) { emptyList() } else { unmigratedV1Members @@ -181,11 +184,11 @@ class GroupRecord( } fun isAdmin(recipient: Recipient): Boolean { - return isV2Group && requireV2GroupProperties().isAdmin(recipient) + return hasV2GroupProperties && requireV2GroupProperties().isAdmin(recipient) } fun memberLevel(recipient: Recipient): GroupTable.MemberLevel { - return if (isV2Group) { + return if (hasV2GroupProperties) { val memberLevel = requireV2GroupProperties().memberLevel(recipient.serviceId) if (recipient.isSelf && memberLevel == GroupTable.MemberLevel.NOT_A_MEMBER) { requireV2GroupProperties().memberLevel(Optional.ofNullable(SignalStore.account.pni)) @@ -205,7 +208,7 @@ class GroupRecord( * Whether or not the recipient is a pending member. */ fun isPendingMember(recipient: Recipient): Boolean { - if (isV2Group) { + if (hasV2GroupProperties) { val serviceId = recipient.serviceId if (serviceId.isPresent) { return DecryptedGroupUtil.findPendingByServiceId(requireV2GroupProperties().decryptedGroup.pendingMembers, serviceId.get()) 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 c0fbf15863..216c045b32 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java @@ -14,7 +14,6 @@ import org.signal.libsignal.zkgroup.groups.GroupSecretParams; import org.signal.libsignal.zkgroup.groups.UuidCiphertext; import org.signal.storageservice.storage.protos.groups.ExternalGroupCredential; import org.signal.storageservice.storage.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; @@ -114,6 +113,7 @@ public final class GroupManager { } SignalDatabase.recipients().getByGroupId(groupId).ifPresent(id -> SignalDatabase.messages().deleteScheduledMessages(id)); + SignalDatabase.groups().clearGroupIfLeftAndDeleted(groupId); } @WorkerThread @@ -136,6 +136,7 @@ public final class GroupManager { edit.addMemberAdminsAndLeaveGroup(newAdmins); Log.i(TAG, "Left group " + groupId); } + SignalDatabase.groups().clearGroupIfLeftAndDeleted(groupId); } @WorkerThread @@ -283,13 +284,7 @@ public final class GroupManager { @NonNull RecipientId recipientId) throws GroupChangeBusyException, IOException, GroupChangeFailedException, GroupNotAMemberException, GroupInsufficientRightsException { - GroupTable.V2GroupProperties groupProperties = SignalDatabase.groups().requireGroup(groupId).requireV2GroupProperties(); - Recipient recipient = Recipient.resolved(recipientId); - - if (groupProperties.getBannedMembers().contains(recipient.requireServiceId())) { - Log.i(TAG, "Attempt to ban already banned recipient: " + recipientId); - return; - } + Recipient recipient = Recipient.resolved(recipientId); try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) { editor.ban(recipient.requireServiceId()); @@ -393,10 +388,8 @@ public final class GroupManager { throw new GroupChangeFailedException("Not gv2"); } - GroupRecord groupRecord = SignalDatabase.groups().requireGroup(groupId); - try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) { - return editor.addMembers(newMembers, groupRecord.requireV2GroupProperties().getBannedMembers()); + return editor.addMembers(newMembers); } } 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 2de85ab3f4..9a2d257a45 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java @@ -10,6 +10,7 @@ import androidx.annotation.WorkerThread; import org.signal.core.models.ServiceId; import org.signal.core.models.ServiceId.ACI; import org.signal.core.models.ServiceId.PNI; +import org.signal.core.util.StreamUtil; import org.signal.core.util.UuidUtil; import org.signal.core.util.logging.Log; import org.signal.libsignal.zkgroup.InvalidInputException; @@ -155,10 +156,13 @@ final class GroupManagerV2 { @NonNull ExternalGroupCredential getExternalGroupCredential(@NonNull GroupId.V2 groupId) throws IOException, VerificationFailedException { - GroupMasterKey groupMasterKey = SignalDatabase.groups() - .requireGroup(groupId) - .requireV2GroupProperties() - .getGroupMasterKey(); + GroupRecord groupRecord = groupDatabase.requireGroup(groupId); + + if (!groupRecord.getHasV2GroupProperties()) { + throw new IOException("Missing group properties (likely deleted)"); + } + + GroupMasterKey groupMasterKey = groupRecord.requireV2GroupProperties().getGroupMasterKey(); GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey); @@ -167,7 +171,13 @@ final class GroupManagerV2 { @WorkerThread @NonNull Map getUuidCipherTexts(@NonNull GroupId.V2 groupId) { - GroupRecord groupRecord = SignalDatabase.groups().requireGroup(groupId); + GroupRecord groupRecord = groupDatabase.requireGroup(groupId); + + if (!groupRecord.getHasV2GroupProperties()) { + Log.w(TAG, "Missing group properties (likely deleted)"); + return Collections.emptyMap(); + } + GroupTable.V2GroupProperties v2GroupProperties = groupRecord.requireV2GroupProperties(); GroupMasterKey groupMasterKey = v2GroupProperties.getGroupMasterKey(); ClientZkGroupCipher clientZkGroupCipher = new ClientZkGroupCipher(GroupSecretParams.deriveFromMasterKey(groupMasterKey)); @@ -192,8 +202,16 @@ final class GroupManagerV2 { } @WorkerThread - GroupEditor edit(@NonNull GroupId.V2 groupId) throws GroupChangeBusyException { - return new GroupEditor(groupId, GroupsV2ProcessingLock.acquireGroupProcessingLock()); + GroupEditor edit(@NonNull GroupId.V2 groupId) throws GroupChangeBusyException, GroupChangeFailedException { + Closeable lock = GroupsV2ProcessingLock.acquireGroupProcessingLock(); + + GroupRecord groupRecord = groupDatabase.getGroup(groupId).orElse(null); + if (groupRecord == null || !groupRecord.getHasV2GroupProperties()) { + StreamUtil.close(lock); + throw new GroupChangeFailedException("Missing group properties, likely deleted."); + } + + return new GroupEditor(groupRecord, lock); } @WorkerThread @@ -202,13 +220,18 @@ final class GroupManagerV2 { } @WorkerThread - GroupJoiner cancelRequest(@NonNull GroupId.V2 groupId) throws GroupChangeBusyException { - GroupMasterKey groupMasterKey = SignalDatabase.groups() - .requireGroup(groupId) - .requireV2GroupProperties() - .getGroupMasterKey(); + GroupJoiner cancelRequest(@NonNull GroupId.V2 groupId) throws GroupChangeBusyException, GroupChangeFailedException { + Closeable lock = GroupsV2ProcessingLock.acquireGroupProcessingLock(); - return new GroupJoiner(groupMasterKey, null, GroupsV2ProcessingLock.acquireGroupProcessingLock()); + GroupRecord groupRecord = groupDatabase.getGroup(groupId).orElse(null); + if (groupRecord == null || !groupRecord.getHasV2GroupProperties()) { + StreamUtil.close(lock); + throw new GroupChangeFailedException("Missing group properties, likely deleted."); + } + + GroupMasterKey groupMasterKey = groupRecord.requireV2GroupProperties().getGroupMasterKey(); + + return new GroupJoiner(groupMasterKey, null, lock); } @WorkerThread @@ -277,12 +300,10 @@ final class GroupManagerV2 { private final GroupSecretParams groupSecretParams; private final GroupsV2Operations.GroupOperations groupOperations; - GroupEditor(@NonNull GroupId.V2 groupId, @NonNull Closeable lock) { + GroupEditor(@NonNull GroupRecord groupRecord, @NonNull Closeable lock) { super(lock); - GroupRecord groupRecord = groupDatabase.requireGroup(groupId); - - this.groupId = groupId; + this.groupId = groupRecord.getId().requireV2(); this.v2GroupProperties = groupRecord.requireV2GroupProperties(); this.groupMasterKey = v2GroupProperties.getGroupMasterKey(); this.groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey); @@ -290,7 +311,7 @@ final class GroupManagerV2 { } @WorkerThread - @NonNull GroupManager.GroupActionResult addMembers(@NonNull Collection newMembers, @NonNull Set bannedMembers) + @NonNull GroupManager.GroupActionResult addMembers(@NonNull Collection newMembers) throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException, MembershipNotSuitableForV2Exception { if (!GroupsV2CapabilityChecker.allHaveServiceId(newMembers)) { @@ -303,7 +324,7 @@ final class GroupManagerV2 { groupCandidates = GroupCandidate.withoutExpiringProfileKeyCredentials(groupCandidates); } - return commitChangeWithConflictResolution(selfAci, groupOperations.createModifyGroupMembershipChange(groupCandidates, bannedMembers, selfAci)); + return commitChangeWithConflictResolution(selfAci, groupOperations.createModifyGroupMembershipChange(groupCandidates, v2GroupProperties.getBannedMembers(), selfAci)); } @WorkerThread @@ -452,8 +473,7 @@ final class GroupManagerV2 { void leaveGroup(boolean sendToMembers) throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException { - GroupRecord groupRecord = groupDatabase.requireGroup(groupId); - DecryptedGroup decryptedGroup = groupRecord.requireV2GroupProperties().getDecryptedGroup(); + DecryptedGroup decryptedGroup = v2GroupProperties.getDecryptedGroup(); Optional selfMember = DecryptedGroupUtil.findMemberByAci(decryptedGroup.members, selfAci); Optional aciPendingMember = DecryptedGroupUtil.findPendingByServiceId(decryptedGroup.pendingMembers, selfAci); Optional pniPendingMember = DecryptedGroupUtil.findPendingByServiceId(decryptedGroup.pendingMembers, selfPni); @@ -508,7 +528,7 @@ final class GroupManagerV2 { throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException { ProfileKey profileKey = ProfileKeyUtil.getSelfProfileKey(); - DecryptedGroup group = groupDatabase.requireGroup(groupId).requireV2GroupProperties().getDecryptedGroup(); + DecryptedGroup group = v2GroupProperties.getDecryptedGroup(); Optional selfInGroup = DecryptedGroupUtil.findMemberByAci(group.members, selfAci); if (selfInGroup.isEmpty()) { @@ -544,7 +564,7 @@ final class GroupManagerV2 { @Nullable GroupManager.GroupActionResult acceptInvite() throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException { - DecryptedGroup group = groupDatabase.requireGroup(groupId).requireV2GroupProperties().getDecryptedGroup(); + DecryptedGroup group = v2GroupProperties.getDecryptedGroup(); Optional selfInGroup = DecryptedGroupUtil.findMemberByAci(group.members, selfAci); if (selfInGroup.isPresent()) { @@ -581,6 +601,14 @@ final class GroupManagerV2 { public GroupManager.GroupActionResult ban(ServiceId serviceId) throws GroupChangeFailedException, GroupNotAMemberException, GroupInsufficientRightsException, IOException { + if (v2GroupProperties.getBannedMembers().contains(serviceId)) { + Log.i(TAG, "Attempt to ban already banned recipient"); + + Recipient groupRecipient = Recipient.externalGroupExact(groupId); + long threadId = SignalDatabase.threads().getOrCreateThreadIdFor(groupRecipient); + return new GroupManager.GroupActionResult(groupRecipient, threadId, 0, Collections.emptyList()); + } + ByteString serviceIdByteString = serviceId.toByteString(); boolean rejectJoinRequest = v2GroupProperties.getDecryptedGroup().requestingMembers.stream().anyMatch(m -> m.aciBytes.equals(serviceIdByteString)); @@ -610,7 +638,7 @@ final class GroupManagerV2 { GroupChange.Actions.Builder change = groupOperations.createChangeJoinByLinkRights(access); if (state != GroupManager.GroupLinkState.DISABLED) { - DecryptedGroup group = groupDatabase.requireGroup(groupId).requireV2GroupProperties().getDecryptedGroup(); + DecryptedGroup group = v2GroupProperties.getDecryptedGroup(); if (group.inviteLinkPassword.size() == 0) { Log.d(TAG, "First time enabling group links for group and password empty, generating"); @@ -682,6 +710,11 @@ final class GroupManagerV2 { throw new GroupChangeFailedException(); } + if (!groupDatabase.requireGroup(groupId).getHasV2GroupProperties()) { + Log.w(TAG, "Group does not have properties, likely deleted."); + throw new GroupChangeFailedException(); + } + if (groupUpdateResult.getUpdateStatus() != GroupUpdateResult.UpdateStatus.GROUP_UPDATED) { int serverRevision = groupUpdateResult.getLatestServer().revision; int localRevision = groupDatabase.requireGroup(groupId).requireV2GroupProperties().getGroupRevision(); @@ -726,7 +759,11 @@ final class GroupManagerV2 { private GroupManager.GroupActionResult commitChange(@NonNull GroupChange.Actions.Builder change, boolean allowWhenBlocked, boolean sendToMembers) throws GroupNotAMemberException, GroupChangeFailedException, IOException, GroupInsufficientRightsException { - final GroupRecord groupRecord = groupDatabase.requireGroup(groupId); + final GroupRecord groupRecord = groupDatabase.requireGroup(groupId); + if (!groupRecord.getHasV2GroupProperties()) { + throw new GroupChangeFailedException("Missing group properties, likely deleted."); + } + final GroupTable.V2GroupProperties v2GroupProperties = groupRecord.requireV2GroupProperties(); final int nextRevision = v2GroupProperties.getGroupRevision() + 1; final GroupChange.Actions changeActions = change.version(nextRevision).build(); @@ -1257,7 +1294,12 @@ final class GroupManagerV2 { throw new GroupChangeFailedException(e); } - DecryptedGroup decryptedGroup = groupDatabase.requireGroup(groupId).requireV2GroupProperties().getDecryptedGroup(); + GroupRecord groupRecord = groupDatabase.getGroup(groupId).orElse(null); + if (groupRecord == null || !groupRecord.getHasV2GroupProperties()) { + throw new GroupChangeFailedException("Missing group properties, likely deleted"); + } + + DecryptedGroup decryptedGroup = groupRecord.requireV2GroupProperties().getDecryptedGroup(); try { //noinspection OptionalGetWithoutIsPresent 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 eadcc6e4f3..1baeef636c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java @@ -62,8 +62,11 @@ public final class LiveGroup { this.requestingMembers = mapToRequestingMembers(this.groupRecord); if (groupId.isV2()) { - LiveData v2Properties = Transformations.map(this.groupRecord, GroupRecord::requireV2GroupProperties); + LiveData v2Properties = Transformations.map(this.groupRecord, g -> g.getHasV2GroupProperties() ? g.requireV2GroupProperties() : null); this.groupLink = Transformations.map(v2Properties, g -> { + if (g == null) { + return GroupLinkUrlAndStatus.NONE; + } DecryptedGroup group = g.getDecryptedGroup(); AccessControl.AccessRequired addFromInviteLink = group.accessControl != null ? group.accessControl.addFromInviteLink : new AccessControl().addFromInviteLink; @@ -98,7 +101,7 @@ public final class LiveGroup { protected static LiveData> mapToRequestingMembers(@NonNull LiveData groupRecord) { return LiveDataUtil.mapAsync(groupRecord, g -> { - if (!g.isV2Group()) { + if (!g.getHasV2GroupProperties()) { return Collections.emptyList(); } @@ -144,7 +147,7 @@ public final class LiveGroup { } public LiveData> getBannedMembers() { - return Transformations.map(groupRecord, g -> g.isV2Group() ? g.requireV2GroupProperties().getBannedMembers() : Collections.emptySet()); + return Transformations.map(groupRecord, g -> g.getHasV2GroupProperties() ? g.requireV2GroupProperties().getBannedMembers() : Collections.emptySet()); } public LiveData isActive() { @@ -164,12 +167,12 @@ public final class LiveGroup { } public LiveData getPendingMemberCount() { - return Transformations.map(groupRecord, g -> g.isV2Group() ? g.requireV2GroupProperties().getDecryptedGroup().pendingMembers.size() : 0); + return Transformations.map(groupRecord, g -> g.getHasV2GroupProperties() ? g.requireV2GroupProperties().getDecryptedGroup().pendingMembers.size() : 0); } public LiveData getPendingAndRequestingMemberCount() { return Transformations.map(groupRecord, g -> { - if (g.isV2Group()) { + if (g.getHasV2GroupProperties()) { DecryptedGroup decryptedGroup = g.requireV2GroupProperties().getDecryptedGroup(); return decryptedGroup.pendingMembers.size() + decryptedGroup.requestingMembers.size(); 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 c077c05334..c507c9df74 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 @@ -56,6 +56,7 @@ public final class LeaveGroupDialog { SimpleTask.run(activity.getLifecycle(), () -> { GroupTable.V2GroupProperties groupProperties = SignalDatabase.groups() .getGroup(groupId) + .filter(GroupRecord::getHasV2GroupProperties) .map(GroupRecord::requireV2GroupProperties) .orElse(null); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/invited/PendingMemberInvitesRepository.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/invited/PendingMemberInvitesRepository.java index 9c0b09c994..c94dcad2b1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/invited/PendingMemberInvitesRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/invited/PendingMemberInvitesRepository.java @@ -15,6 +15,7 @@ import org.signal.storageservice.storage.protos.groups.local.DecryptedGroup; import org.signal.storageservice.storage.protos.groups.local.DecryptedPendingMember; 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; @@ -50,8 +51,15 @@ final class PendingMemberInvitesRepository { public void getInvitees(@NonNull Consumer onInviteesLoaded) { executor.execute(() -> { - GroupTable groupDatabase = SignalDatabase.groups(); - GroupTable.V2GroupProperties v2GroupProperties = groupDatabase.getGroup(groupId).get().requireV2GroupProperties(); + GroupTable groupDatabase = SignalDatabase.groups(); + GroupRecord groupRecord = groupDatabase.getGroup(groupId).orElse(null); + + if (groupRecord == null || !groupRecord.getHasV2GroupProperties()) { + Log.w(TAG, "Missing group, likely deleted."); + return; + } + + GroupTable.V2GroupProperties v2GroupProperties = groupRecord.requireV2GroupProperties(); DecryptedGroup decryptedGroup = v2GroupProperties.getDecryptedGroup(); List pendingMembersList = decryptedGroup.pendingMembers; List byMe = new ArrayList<>(pendingMembersList.size()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.kt index 2f86c35019..c9c414ad18 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.kt @@ -115,7 +115,7 @@ class GroupsV2StateProcessor private constructor( @Throws(IOException::class, GroupNotAMemberException::class) fun forceSanityUpdateFromServer(timestamp: Long): GroupUpdateResult { val groupRecord = SignalDatabase.groups.getGroup(groupId).orNull() - val currentLocalState: DecryptedGroup? = groupRecord?.requireV2GroupProperties()?.decryptedGroup?.let { if (it.isEmptyPlaceholder()) null else it } + val currentLocalState: DecryptedGroup? = groupRecord?.takeIf { it.hasV2GroupProperties }?.requireV2GroupProperties()?.decryptedGroup?.let { if (it.isEmptyPlaceholder()) null else it } if (currentLocalState == null) { Log.i(TAG, "$logPrefix No local state to force update") @@ -184,7 +184,7 @@ class GroupsV2StateProcessor private constructor( return GroupUpdateResult(GroupUpdateResult.UpdateStatus.GROUP_CONSISTENT_OR_AHEAD, null) } - val currentLocalState: DecryptedGroup? = groupRecord.map { it.requireV2GroupProperties().decryptedGroup }.orNull()?.let { if (it.isEmptyPlaceholder()) null else it } + val currentLocalState: DecryptedGroup? = groupRecord.filter { it.hasV2GroupProperties }.map { it.requireV2GroupProperties().decryptedGroup }.orNull()?.let { if (it.isEmptyPlaceholder()) null else it } if (signedGroupChange != null && canApplyP2pChange(targetRevision, signedGroupChange, currentLocalState, groupRecord)) { when (val p2pUpdateResult = updateViaPeerGroupChange(timestamp, serverGuid, signedGroupChange, currentLocalState!!, forceApply = false)) { @@ -307,7 +307,7 @@ class GroupsV2StateProcessor private constructor( serverGuid: String?, groupRecord: Optional = SignalDatabase.groups.getGroup(groupId) ): InternalUpdateResult { - var currentLocalState: DecryptedGroup? = groupRecord.map { it.requireV2GroupProperties().decryptedGroup }.orNull()?.let { if (it.isEmptyPlaceholder()) null else it } + var currentLocalState: DecryptedGroup? = groupRecord.filter { it.hasV2GroupProperties }.map { it.requireV2GroupProperties().decryptedGroup }.orNull()?.let { if (it.isEmptyPlaceholder()) null else it } if (targetRevision == LATEST && (currentLocalState == null || currentLocalState.revision == RESTORE_PLACEHOLDER_REVISION)) { Log.i(TAG, "$logPrefix Latest revision only, update to latest directly") @@ -865,6 +865,11 @@ class GroupsV2StateProcessor private constructor( return } + if (!group.hasV2GroupProperties) { + Log.w(TAG, "Group $groupId has no properties (likely deleted).") + return + } + val groupRecipient = Recipient.externalGroupExact(groupId) val decryptedGroup = group.requireV2GroupProperties().decryptedGroup @@ -895,6 +900,11 @@ class GroupsV2StateProcessor private constructor( return } + if (!group.hasV2GroupProperties) { + Log.w(TAG, "Group has no properties, likely deleted") + return + } + val decryptedGroup = group.requireV2GroupProperties().decryptedGroup if (decryptedGroup.requestingMembers.none { ACI.parseOrNull(it.aciBytes) == aci }) { 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 364384bc4c..adb6b77513 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/AvatarGroupsV2DownloadJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/AvatarGroupsV2DownloadJob.java @@ -51,7 +51,14 @@ public final class AvatarGroupsV2DownloadJob extends BaseJob { public static void enqueueUnblurredAvatar(@NonNull GroupId.V2 groupId) { SignalExecutors.BOUNDED.execute(() -> { - String cdnKey = SignalDatabase.groups().getGroup(groupId).get().requireV2GroupProperties().getAvatarKey(); + GroupRecord groupRecord = SignalDatabase.groups().getGroup(groupId).orElse(null); + + if (groupRecord == null || !groupRecord.getHasV2GroupProperties()) { + Log.w(TAG, "Missing group properties (probably previously deleted)"); + return; + } + + String cdnKey = groupRecord.requireV2GroupProperties().getAvatarKey(); AppDependencies.getJobManager().add(new AvatarGroupsV2DownloadJob(groupId, cdnKey, true)); }); } @@ -100,8 +107,8 @@ public final class AvatarGroupsV2DownloadJob extends BaseJob { File attachment = null; try { - if (!record.isPresent()) { - Log.w(TAG, "Cannot download avatar for unknown group"); + if (!record.isPresent() || !record.get().getHasV2GroupProperties()) { + Log.w(TAG, "Cannot download avatar for unknown group/group with no properties"); return; } 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 0a73929fd0..46a7b38228 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/ForceUpdateGroupV2Job.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/ForceUpdateGroupV2Job.java @@ -31,7 +31,7 @@ public final class ForceUpdateGroupV2Job extends BaseJob { SignalExecutors.BOUNDED.execute(() -> { Optional group = SignalDatabase.groups().getGroup(groupId); if (group.isPresent() && - group.get().isV2Group() && + group.get().getHasV2GroupProperties() && group.get().getLastForceUpdateTimestamp() + FORCE_UPDATE_INTERVAL < System.currentTimeMillis() ) { AppDependencies.getJobManager().add(new ForceUpdateGroupV2Job(groupId)); 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 2cfacbfdcb..f6a5285327 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/ForceUpdateGroupV2WorkerJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/ForceUpdateGroupV2WorkerJob.java @@ -79,6 +79,11 @@ final class ForceUpdateGroupV2WorkerJob extends BaseJob { return; } + if (group.isPresent() && !group.get().getHasV2GroupProperties()) { + Log.i(TAG, "Group is deleted, skipping force update."); + return; + } + GroupManager.forceSanityUpdateFromServer(context, group.get().requireV2GroupProperties().getGroupMasterKey(), System.currentTimeMillis()); SignalDatabase.groups().setLastForceUpdateTimestamp(group.get().getId(), System.currentTimeMillis()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/GroupCallUpdateSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/GroupCallUpdateSendJob.java index 04dcfe143c..b8e24c5b33 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/GroupCallUpdateSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/GroupCallUpdateSendJob.java @@ -20,6 +20,7 @@ import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.RecipientUtil; import org.thoughtcrime.securesms.transport.RetryLaterException; +import org.thoughtcrime.securesms.transport.UndeliverableMessageException; import org.thoughtcrime.securesms.util.GroupUtil; import org.whispersystems.signalservice.api.crypto.ContentHint; import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; @@ -116,6 +117,11 @@ public class GroupCallUpdateSendJob extends BaseJob { throw new NotPushRegisteredException(); } + if (!SignalDatabase.recipients().containsId(recipientId)) { + Log.w(TAG, "Missing recipient record for id."); + return; + } + RecipientRecord conversationRecipient = SignalDatabase.recipients().getRecord(recipientId); if (conversationRecipient.getGroupId() == null || !conversationRecipient.getGroupId().isV2()) { @@ -169,7 +175,7 @@ public class GroupCallUpdateSendJob extends BaseJob { } private @NonNull List deliver(@NonNull GroupId groupId, @NonNull List destinations) - throws IOException, UntrustedIdentityException, NoSessionException + throws IOException, UntrustedIdentityException, NoSessionException, UndeliverableMessageException { SignalServiceDataMessage.Builder dataMessageBuilder = SignalServiceDataMessage.newBuilder() .withTimestamp(System.currentTimeMillis()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/GroupDeletedBackfillWorkerJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/GroupDeletedBackfillWorkerJob.kt new file mode 100644 index 0000000000..0196845862 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/GroupDeletedBackfillWorkerJob.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.jobs + +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.jobmanager.Job + +/** + * Clears metadata of existing deleted groups (left and thread deleted). Each group's state is re-validated at run time, + * so any group that has settled back to active (or regained a thread) is skipped. + */ +class GroupDeletedBackfillWorkerJob private constructor(parameters: Parameters) : Job(parameters) { + + companion object { + val TAG = Log.tag(GroupDeletedBackfillWorkerJob::class.java) + const val KEY = "GroupDeletedBackfillWorkerJob" + } + + constructor() : this( + Parameters.Builder() + .setQueue(KEY) + .setMaxInstancesForFactory(2) + .setLifespan(Parameters.IMMORTAL) + .setMaxAttempts(3) + .build() + ) + + override fun serialize(): ByteArray? = null + + override fun getFactoryKey(): String = KEY + + override fun run(): Result { + val groupIdsToClear = SignalDatabase.groups.getGroups().use { groups -> + groups + .asSequence() + .filter { !it.isActive && !SignalDatabase.threads.hasActiveThread(it.recipientId) } + .map { it.id } + .toList() + } + + groupIdsToClear.forEach { id -> + SignalDatabase.groups.clearGroupIfLeftAndDeleted(id) + } + + Log.i(TAG, "Cleared ${groupIdsToClear.size} group(s) during backfill.") + return Result.success() + } + + override fun onFailure() = Unit + + class Factory : Job.Factory { + override fun create(parameters: Parameters, serializedData: ByteArray?): GroupDeletedBackfillWorkerJob { + return GroupDeletedBackfillWorkerJob(parameters) + } + } +} 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 d4834528c5..eb2d7a53c3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/GroupV2UpdateSelfProfileKeyJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/GroupV2UpdateSelfProfileKeyJob.java @@ -118,7 +118,7 @@ public final class GroupV2UpdateSelfProfileKeyJob extends BaseJob { for (GroupId.V2 id : SignalDatabase.groups().getAllGroupV2Ids()) { Optional group = SignalDatabase.groups().getGroup(id); - if (!group.isPresent()) { + if (!group.isPresent() || !group.get().getHasV2GroupProperties()) { Log.w(TAG, "Group " + group + " no longer exists?"); continue; } @@ -190,6 +190,11 @@ public final class GroupV2UpdateSelfProfileKeyJob extends BaseJob { return; } + if (!group.get().getHasV2GroupProperties()) { + Log.i(TAG, "Group is missing properties, likely deleted."); + return; + } + Log.i(TAG, "Ensuring profile key up to date on group " + groupId); GroupManager.updateSelfProfileKeyInGroup(context, groupId); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index 2548403c5c..96a511ccdd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -74,6 +74,7 @@ import org.thoughtcrime.securesms.migrations.EmojiSearchEnglishLabelsMigrationJo import org.thoughtcrime.securesms.migrations.EmojiSearchIndexCheckMigrationJob; import org.thoughtcrime.securesms.migrations.FixChangeNumberErrorMigrationJob; import org.thoughtcrime.securesms.migrations.GooglePlayBillingPurchaseTokenMigrationJob; +import org.thoughtcrime.securesms.migrations.GroupDeletedBackfillMigrationJob; import org.thoughtcrime.securesms.migrations.IdentityTableCleanupMigrationJob; import org.thoughtcrime.securesms.migrations.KeyTransparencyUsernameMigrationJob; import org.thoughtcrime.securesms.migrations.LegacyMigrationJob; @@ -186,6 +187,7 @@ public final class JobManagerFactories { put(GroupCallUpdateSendJob.KEY, new GroupCallUpdateSendJob.Factory()); put(GroupCallPeekJob.KEY, new GroupCallPeekJob.Factory()); put(GroupCallPeekWorkerJob.KEY, new GroupCallPeekWorkerJob.Factory()); + put(GroupDeletedBackfillWorkerJob.KEY, new GroupDeletedBackfillWorkerJob.Factory()); put(GroupRingCleanupJob.KEY, new GroupRingCleanupJob.Factory()); put(GroupV2UpdateSelfProfileKeyJob.KEY, new GroupV2UpdateSelfProfileKeyJob.Factory()); put(InAppPaymentAuthCheckJob.KEY, new InAppPaymentAuthCheckJob.Factory()); @@ -342,6 +344,7 @@ public final class JobManagerFactories { put(EmojiSearchIndexCheckMigrationJob.KEY, new EmojiSearchIndexCheckMigrationJob.Factory()); put(FixChangeNumberErrorMigrationJob.KEY, new FixChangeNumberErrorMigrationJob.Factory()); put(GooglePlayBillingPurchaseTokenMigrationJob.KEY, new GooglePlayBillingPurchaseTokenMigrationJob.Factory()); + put(GroupDeletedBackfillMigrationJob.KEY, new GroupDeletedBackfillMigrationJob.Factory()); put(IdentityTableCleanupMigrationJob.KEY, new IdentityTableCleanupMigrationJob.Factory()); put(KeyTransparencyUsernameMigrationJob.KEY, new KeyTransparencyUsernameMigrationJob.Factory()); put(LegacyMigrationJob.KEY, new LegacyMigrationJob.Factory()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceMessageRequestResponseJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceMessageRequestResponseJob.java index 38954d194a..0d6b1bda70 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceMessageRequestResponseJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceMessageRequestResponseJob.java @@ -107,6 +107,11 @@ public class MultiDeviceMessageRequestResponseJob extends BaseJob { return; } + if (!SignalDatabase.recipients().containsId(threadRecipient)) { + Log.i(TAG, "Missing record for recipient, likely a deleted group"); + return; + } + SignalServiceMessageSender messageSender = AppDependencies.getSignalServiceMessageSender(); RecipientRecord recipient = SignalDatabase.recipients().getRecord(threadRecipient); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/NullMessageSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/NullMessageSendJob.java index f643f67bff..ce7aab28d3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/NullMessageSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/NullMessageSendJob.java @@ -63,6 +63,11 @@ public class NullMessageSendJob extends BaseJob { @Override protected void onRun() throws Exception { + if (!SignalDatabase.recipients().containsId(recipientId)) { + Log.w(TAG, "Cannot find recipient, likely deleted group."); + return; + } + RecipientRecord recipient = SignalDatabase.recipients().getRecord(recipientId); if (recipient.getGroupId() != null) { 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 116abc9d55..da5d4d2120 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java @@ -119,6 +119,10 @@ public final class PushGroupSendJob extends PushSendJob { boolean isScheduledSend) { try { + if (!SignalDatabase.recipients().containsId(destination)) { + throw new MmsException("Recipient no longer exists, likely deleted group."); + } + RecipientRecord group = SignalDatabase.recipients().getRecord(destination); if (group.getGroupId() == null || !group.getGroupId().isPush()) { throw new AssertionError("Not a group!"); @@ -302,6 +306,10 @@ public final class PushGroupSendJob extends PushSendJob { throw new UndeliverableMessageException("Non-admins cannot send stories in announcement groups!"); } + if (groupRecord.isPresent() && !groupRecord.get().getHasV2GroupProperties()) { + throw new UndeliverableMessageException("Cannot send stories to deleted groups!"); + } + if (groupRecord.isPresent()) { GroupTable.V2GroupProperties v2GroupProperties = groupRecord.get().requireV2GroupProperties(); SignalServiceGroupV2 groupContext = SignalServiceGroupV2.newBuilder(v2GroupProperties.getGroupMasterKey()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/ReactionSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/ReactionSendJob.java index b6c66eaf03..134d545ee4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/ReactionSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/ReactionSendJob.java @@ -28,6 +28,7 @@ import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.RecipientUtil; import org.thoughtcrime.securesms.transport.RetryLaterException; +import org.thoughtcrime.securesms.transport.UndeliverableMessageException; import org.thoughtcrime.securesms.util.GroupUtil; import org.whispersystems.signalservice.api.crypto.ContentHint; import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; @@ -230,7 +231,7 @@ public class ReactionSendJob extends BaseJob { } private @NonNull List deliver(@NonNull RecipientRecord conversationRecipient, @NonNull List destinations, @NonNull Recipient targetAuthor, long targetSentTimestamp) - throws IOException, UntrustedIdentityException, NoSessionException + throws IOException, UntrustedIdentityException, NoSessionException, UndeliverableMessageException { SignalServiceDataMessage.Builder dataMessageBuilder = SignalServiceDataMessage.newBuilder() .withTimestamp(System.currentTimeMillis()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RemoteDeleteSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RemoteDeleteSendJob.java index 8b52cddede..2333534cab 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RemoteDeleteSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RemoteDeleteSendJob.java @@ -29,6 +29,7 @@ import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.RecipientUtil; import org.thoughtcrime.securesms.transport.RetryLaterException; +import org.thoughtcrime.securesms.transport.UndeliverableMessageException; import org.thoughtcrime.securesms.util.GroupUtil; import org.whispersystems.signalservice.api.crypto.ContentHint; import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; @@ -229,7 +230,7 @@ public class RemoteDeleteSendJob extends BaseJob { long targetSentTimestamp, boolean isForStory, @Nullable DistributionListId distributionListId) - throws IOException, UntrustedIdentityException, NoSessionException + throws IOException, UntrustedIdentityException, NoSessionException, UndeliverableMessageException { SignalServiceDataMessage.Builder dataMessageBuilder = SignalServiceDataMessage.newBuilder() .withTimestamp(System.currentTimeMillis()) 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 ffb6991917..7013ec6d20 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RequestGroupV2InfoWorkerJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RequestGroupV2InfoWorkerJob.java @@ -94,6 +94,11 @@ final class RequestGroupV2InfoWorkerJob extends BaseJob { return; } + if (group.isPresent() && !group.get().getHasV2GroupProperties()) { + Log.w(TAG, "Group is deleted, skipping fetch."); + return; + } + GroupManager.updateGroupFromServer(context, group.get().requireV2GroupProperties().getGroupMasterKey(), toRevision, System.currentTimeMillis()); } 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 3d33c70b5b..bbe29fb9f9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java @@ -68,7 +68,7 @@ public final class MessageRequestRepository { if (groupRecord.isPresent()) { List recipients = Recipient.resolvedList(groupRecord.get().getMembers()); - if (groupRecord.get().isV2Group()) { + if (groupRecord.get().getHasV2GroupProperties()) { boolean groupHasExistingContacts = recipients.stream().filter(r -> !r.isSelf()).anyMatch(r -> r.isProfileSharing() || r.isSystemContact()); List membersPreview = recipients.stream().filter(r -> !r.isSelf()).limit(MAX_MEMBER_NAMES).collect(Collectors.toList()); DecryptedGroup decryptedGroup = groupRecord.get().requireV2GroupProperties().getDecryptedGroup(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/DataMessageProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/messages/DataMessageProcessor.kt index 98d71b9123..1a72e17f95 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/DataMessageProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/DataMessageProcessor.kt @@ -1458,7 +1458,7 @@ object DataMessageProcessor { } val groupRecord = SignalDatabase.groups.getGroup(targetThreadRecipientId).orNull() - if (groupRecord == null || !groupRecord.isV2Group) { + if (groupRecord == null || !groupRecord.hasV2GroupProperties) { warn(envelope.clientTimestamp!!, "[handleAdminRemoteDelete] Invalid group.") return null } 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 d5bff320ca..31e0b73d0a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/GroupSendUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/GroupSendUtil.java @@ -262,7 +262,7 @@ public final class GroupSendUtil { RecipientData recipients = new RecipientData(context, registeredTargets, isStorySend); Optional groupRecord = groupId != null ? SignalDatabase.groups().getGroup(groupId) : Optional.empty(); - GroupSendEndorsementRecords groupSendEndorsementRecords = groupRecord.filter(GroupRecord::isV2Group).map(g -> SignalDatabase.groups().getGroupSendEndorsements(g.getId())).orElse(null); + GroupSendEndorsementRecords groupSendEndorsementRecords = groupRecord.filter(GroupRecord::getHasV2GroupProperties).map(g -> SignalDatabase.groups().getGroupSendEndorsements(g.getId())).orElse(null); long groupSendEndorsementExpiration = groupRecord.map(GroupRecord::getGroupSendEndorsementExpiration).orElse(0L); SenderCertificate senderCertificate = SealedSenderAccessUtil.getSealedSenderCertificate(); boolean useGroupSendEndorsements = groupSendEndorsementRecords != null; diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java index f31f8b2c7d..8a338fe757 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java @@ -208,9 +208,10 @@ public class ApplicationMigrations { static final int KT_USERNAME_CAPABILITY = 164; static final int FIX_CHANGE_NUMBER_ERROR_2 = 165; static final int LOCAL_ARCHIVE_RECONCILE = 166; + static final int GROUP_DELETED_AT_BACKFILL = 167; } - public static final int CURRENT_VERSION = 166; + public static final int CURRENT_VERSION = 167; /** * This *must* be called after the {@link JobManager} has been instantiated, but *before* the call @@ -965,6 +966,10 @@ public class ApplicationMigrations { jobs.put(Version.LOCAL_ARCHIVE_RECONCILE, new LocalArchiveReconciliationMigrationJob()); } + if (lastSeenVersion < Version.GROUP_DELETED_AT_BACKFILL) { + jobs.put(Version.GROUP_DELETED_AT_BACKFILL, new GroupDeletedBackfillMigrationJob()); + } + return jobs; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/GroupDeletedBackfillMigrationJob.kt b/app/src/main/java/org/thoughtcrime/securesms/migrations/GroupDeletedBackfillMigrationJob.kt new file mode 100644 index 0000000000..932d86521d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/GroupDeletedBackfillMigrationJob.kt @@ -0,0 +1,35 @@ +package org.thoughtcrime.securesms.migrations + +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.jobmanager.Job +import org.thoughtcrime.securesms.jobs.GroupDeletedBackfillWorkerJob + +/** + * Kicks off clearing metadata of existing deleted groups (left and thread deleted) by enqueueing a [GroupDeletedBackfillWorkerJob]. + */ +internal class GroupDeletedBackfillMigrationJob( + parameters: Parameters = Parameters.Builder().build() +) : MigrationJob(parameters) { + + companion object { + val TAG = Log.tag(GroupDeletedBackfillMigrationJob::class.java) + const val KEY = "GroupDeletedBackfillMigrationJob" + } + + override fun getFactoryKey(): String = KEY + + override fun isUiBlocking(): Boolean = false + + override fun performMigration() { + AppDependencies.jobManager.add(GroupDeletedBackfillWorkerJob()) + } + + override fun shouldRetry(e: Exception): Boolean = false + + class Factory : Job.Factory { + override fun create(parameters: Parameters, serializedData: ByteArray?): GroupDeletedBackfillMigrationJob { + return GroupDeletedBackfillMigrationJob(parameters) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/AvatarHelper.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/AvatarHelper.java index 2a7c103b24..0acce00252 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/AvatarHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/AvatarHelper.java @@ -90,7 +90,7 @@ public class AvatarHelper { } /** - * Deletes and avatar. + * Deletes an avatar. */ public static void delete(@NonNull Context context, @NonNull RecipientId recipientId) { getAvatarFile(context, recipientId).delete(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.kt index 4e6c2cec78..c0694b19bb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.kt @@ -304,6 +304,7 @@ class Recipient( val participantAcis: List get() { return groupRecord + .filter { it.hasV2GroupProperties } .map { it.requireV2GroupProperties().getMemberServiceIds().toImmutableList() } .orElse(emptyList().toImmutableList()) } 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 62936d2481..883eaef743 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientUtil.java @@ -349,7 +349,7 @@ public class RecipientUtil { GroupTable groupDatabase = SignalDatabase.groups(); return groupDatabase.getPushGroupsContainingMember(recipient.getId()) .stream() - .filter(GroupRecord::isV2Group) + .filter(GroupRecord::getHasV2GroupProperties) .anyMatch(group -> group.memberLevel(Recipient.self()).isInGroup()); } 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 808da67248..b951486934 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 @@ -114,7 +114,7 @@ final class RecipientDialogRepository { GroupRecord groupRecord = SignalDatabase.groups().getGroup(groupId.requireV2()).orElse(null); ServiceId.ACI aci = Recipient.resolved(recipientId).getAci().orElse(null); - if (groupRecord != null && aci != null) { + if (groupRecord != null && groupRecord.getHasV2GroupProperties() && aci != null) { return groupRecord.requireV2GroupProperties().adminDemotionClearsLabel(aci); } return false; diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogViewModel.java index 4e44da42bf..2aa0da8069 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogViewModel.java @@ -19,7 +19,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder; import org.signal.core.util.ThreadUtil; import org.signal.core.util.concurrent.SignalExecutors; import org.signal.storageservice.storage.protos.groups.AccessControl; -import org.thoughtcrime.securesms.BlockUnblockDialog; +import org.signal.storageservice.storage.protos.groups.local.DecryptedGroup; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsNavigator; import org.thoughtcrime.securesms.conversation.colors.ColorizerV2; @@ -96,8 +96,9 @@ final class RecipientDialogViewModel extends ViewModel { GroupTable.MemberLevel memberLevel = group.memberLevel(r); boolean inGroup = memberLevel.isInGroup(); boolean recipientAdmin = memberLevel == GroupTable.MemberLevel.ADMINISTRATOR; - AccessControl.AccessRequired linkAccess = group.requireV2GroupProperties().getDecryptedGroup().accessControl != null ? group.requireV2GroupProperties().getDecryptedGroup().accessControl.addFromInviteLink - : AccessControl.AccessRequired.UNKNOWN; + DecryptedGroup decryptedGroup = group.getHasV2GroupProperties() ? group.requireV2GroupProperties().getDecryptedGroup() : null; + AccessControl.AccessRequired linkAccess = decryptedGroup != null && decryptedGroup.accessControl != null ? decryptedGroup.accessControl.addFromInviteLink + : AccessControl.AccessRequired.UNKNOWN; boolean isLinkActive = linkAccess == AccessControl.AccessRequired.ANY || linkAccess == AccessControl.AccessRequired.ADMINISTRATOR; return new AdminActionStatus(active && inGroup && localAdmin, @@ -143,7 +144,7 @@ final class RecipientDialogViewModel extends ViewModel { if (label != null) { ColorizerV2 colorizer = new ColorizerV2(); Optional groupRecord = SignalDatabase.groups().getGroup(v2GroupId); - if (groupRecord.isPresent()) { + if (groupRecord.isPresent() && groupRecord.get().getHasV2GroupProperties()) { colorizer.onGroupMembershipChanged(groupRecord.get().requireV2GroupProperties().getMemberServiceIds()); } styledLabel = new StyledMemberLabel(label, colorizer.getIncomingGroupSenderColor(context, recipient)); diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkRepository.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkRepository.java index fbdb008498..37c5024023 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/ShareableGroupLinkRepository.java @@ -6,8 +6,10 @@ import androidx.annotation.NonNull; import androidx.annotation.WorkerThread; import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; import org.signal.storageservice.storage.protos.groups.AccessControl; import org.thoughtcrime.securesms.database.SignalDatabase; +import org.thoughtcrime.securesms.database.model.GroupRecord; import org.thoughtcrime.securesms.groups.GroupChangeBusyException; import org.thoughtcrime.securesms.groups.GroupChangeFailedException; import org.thoughtcrime.securesms.groups.GroupId; @@ -21,6 +23,8 @@ import java.io.IOException; final class ShareableGroupLinkRepository { + private static final String TAG = Log.tag(ShareableGroupLinkRepository.class); + private final Context context; private final GroupId.V2 groupId; @@ -53,8 +57,16 @@ final class ShareableGroupLinkRepository { @NonNull AsynchronousCallback.WorkerThread callback) { SignalExecutors.UNBOUNDED.execute(() -> { + GroupRecord groupRecord = SignalDatabase.groups().getGroup(groupId).orElse(null); + + if (groupRecord == null || !groupRecord.getHasV2GroupProperties()) { + Log.w(TAG, "Unable to find group, likely deleted."); + callback.onError(GroupChangeFailureReason.OTHER); + return; + } + try { - GroupManager.setGroupLinkEnabledState(context, groupId, toggleGroupLinkState(toggleEnabled, toggleApprovalNeeded)); + GroupManager.setGroupLinkEnabledState(context, groupId, toggleGroupLinkState(groupRecord, toggleEnabled, toggleApprovalNeeded)); callback.onComplete(null); } catch (GroupNotAMemberException | GroupChangeFailedException | GroupInsufficientRightsException | IOException | GroupChangeBusyException e) { callback.onError(GroupChangeFailureReason.fromException(e)); @@ -63,14 +75,12 @@ final class ShareableGroupLinkRepository { } @WorkerThread - private GroupManager.GroupLinkState toggleGroupLinkState(boolean toggleEnabled, boolean toggleApprovalNeeded) { - AccessControl.AccessRequired currentState = SignalDatabase.groups() - .getGroup(groupId) - .get() - .requireV2GroupProperties() - .getDecryptedGroup() - .accessControl - .addFromInviteLink; + private GroupManager.GroupLinkState toggleGroupLinkState(@NonNull GroupRecord groupRecord, boolean toggleEnabled, boolean toggleApprovalNeeded) { + //noinspection DataFlowIssue + AccessControl.AccessRequired currentState = groupRecord.requireV2GroupProperties() + .getDecryptedGroup() + .accessControl + .addFromInviteLink; boolean enabled; boolean approvalNeeded; diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/PinnedMessageManager.kt b/app/src/main/java/org/thoughtcrime/securesms/service/PinnedMessageManager.kt index 733edec7fd..d0182d3657 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/PinnedMessageManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/service/PinnedMessageManager.kt @@ -10,6 +10,7 @@ import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.transport.UndeliverableMessageException import org.thoughtcrime.securesms.util.GroupUtil import org.thoughtcrime.securesms.util.NetworkUtil import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage @@ -62,7 +63,12 @@ class PinnedMessageManager( val conversationRecipient = SignalDatabase.threads.getRecipientForThreadId(record.threadId) ?: continue if (conversationRecipient.isGroup) { - GroupUtil.setDataMessageGroupContext(application, dataMessageBuilder, conversationRecipient.requireGroupId().requirePush()) + try { + GroupUtil.setDataMessageGroupContext(application, dataMessageBuilder, conversationRecipient.requireGroupId().requirePush()) + } catch (e: UndeliverableMessageException) { + Log.w(TAG, "Cannot attach group context for unpin sync of message ${record.id}, likely deleted group. Skipping. Other devices will expire the pin independently.", e) + continue + } } // Best-effort attempt so that messages expire at the same time across devices but if it fails, we can ignore. 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 18f0e96acf..0ca711679d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/GroupUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/GroupUtil.java @@ -19,6 +19,7 @@ import org.thoughtcrime.securesms.messages.SignalServiceProtoUtil; import org.thoughtcrime.securesms.mms.MessageGroupContext; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.transport.UndeliverableMessageException; import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2; import org.whispersystems.signalservice.internal.push.Content; @@ -76,10 +77,16 @@ public final class GroupUtil { public static void setDataMessageGroupContext(@NonNull Context context, @NonNull SignalServiceDataMessage.Builder dataMessageBuilder, @NonNull GroupId.Push groupId) + throws UndeliverableMessageException { if (groupId.isV2()) { - GroupTable groupDatabase = SignalDatabase.groups(); - GroupRecord groupRecord = groupDatabase.requireGroup(groupId); + GroupTable groupDatabase = SignalDatabase.groups(); + GroupRecord groupRecord = groupDatabase.requireGroup(groupId); + + if (!groupRecord.getHasV2GroupProperties()) { + throw new UndeliverableMessageException("No group properties for V2 group " + groupId + ", likely deleted; cannot attach group context."); + } + GroupTable.V2GroupProperties v2GroupProperties = groupRecord.requireV2GroupProperties(); SignalServiceGroupV2 group = SignalServiceGroupV2.newBuilder(v2GroupProperties.getGroupMasterKey()) .withRevision(v2GroupProperties.getGroupRevision()) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0bfd378097..39169ff0f1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -762,17 +762,28 @@ No unread chats - Delete selected chat? - Delete selected chats? + Delete message history? + Delete message histories? + - This will permanently delete the selected chat. - This will permanently delete all %1$d selected chats. + All messages in this chat will be deleted. + All messages in these chats will be deleted. - + + + All messages in this chat will be deleted. You will still be a member of this group. + All messages in these chats will be deleted. You will still be a member of any groups you have not left yet. + + - This will permanently delete the selected chat from all your devices. - This will permanently delete all %1$d selected chats from all your devices. + All messages in this chat will be deleted from all your devices. + All messages in these chats will be deleted from all your devices. + + + + All messages in this chat will be deleted from all your devices. You will still be a member of this group. + All messages in these chats will be deleted from all your devices. You will still be a member of any groups you have not left yet. Deleting diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/GroupTableTest.kt b/app/src/test/java/org/thoughtcrime/securesms/database/GroupTableTest.kt index e46a4751a2..8173168cbb 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/database/GroupTableTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/database/GroupTableTest.kt @@ -6,6 +6,10 @@ package org.thoughtcrime.securesms.database import android.app.Application +import assertk.assertThat +import assertk.assertions.isEmpty +import assertk.assertions.isNotEmpty +import io.mockk.every import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -15,6 +19,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import org.signal.core.util.SqlUtil import org.signal.core.util.deleteAll import org.signal.core.util.readToList import org.signal.core.util.requireLong @@ -38,16 +43,20 @@ class GroupTableTest { val recipients = RecipientTestRule() private lateinit var groupTable: GroupTable + private lateinit var threadTable: ThreadTable private lateinit var alice: RecipientId private lateinit var bob: RecipientId @Before fun setUp() { groupTable = SignalDatabase.groups + threadTable = SignalDatabase.threads groupTable.writableDatabase.deleteAll(GroupTable.TABLE_NAME) groupTable.writableDatabase.deleteAll(GroupTable.MembershipTable.TABLE_NAME) + threadTable.writableDatabase.deleteAll(ThreadTable.TABLE_NAME) + alice = recipients.createRecipient("Buddy #0") bob = recipients.createRecipient("Buddy #1") } @@ -70,6 +79,146 @@ class GroupTableTest { assertEquals(2, members.size) } + @Test + fun givenALeftGroup_whenIDeleteGroup_thenIExpectGroupDeleted() { + val groupId = insertPushGroup() + val threadId = insertThread(groupId) + + groupTable.setMember(groupId, false) + + threadTable.deleteConversation(threadId) + groupTable.clearGroupIfLeftAndDeleted(groupId) + + assertFalse(groupTable.getGroup(groupId).isPresent) + } + + @Test + fun givenATerminatedGroup_whenIDeleteGroup_thenIExpectGroupDeleted() { + val groupId = insertPushGroup() + val threadId = insertThread(groupId) + + groupTable.setMember(groupId, true) + groupTable.setTerminatedBy(groupId, alice) + threadTable.deleteConversation(threadId) + + groupTable.clearGroupIfLeftAndDeleted(groupId) + + assertFalse(groupTable.getGroup(groupId).isPresent) + } + + @Test + fun givenALeftGroup_whenIDeleteGroup_thenIExpectMembershipDeleted() { + val groupId = insertPushGroup() + val threadId = insertThread(groupId) + + groupTable.setMember(groupId, false) + threadTable.deleteConversation(threadId) + + groupTable.clearGroupIfLeftAndDeleted(groupId) + + val remainingMembers = groupTable.getGroupMembers(groupId, GroupTable.MemberSet.FULL_MEMBERS_INCLUDING_SELF) + + assertTrue(remainingMembers.isEmpty()) + } + + @Test + fun givenAGroupWeAreStillAMemberOf_whenIDeleteGroup_thenIExpectGroupRetained() { + val groupId = insertPushGroup() + val threadId = insertThread(groupId) + + threadTable.deleteConversation(threadId) + groupTable.clearGroupIfLeftAndDeleted(groupId) + + assertTrue(groupTable.getGroup(groupId).isPresent) + } + + @Test + fun givenAGroup_whenILeave_thenIExpectGroupRetained() { + val groupId = insertPushGroup() + val threadId = insertThread(groupId) + SignalDatabase.threads.markAsActiveEarly(threadId) + groupTable.setMember(groupId, false) + + groupTable.clearGroupIfLeftAndDeleted(groupId) + + assertTrue(groupTable.getGroup(groupId).isPresent) + } + + @Test + fun givenALeftGroup_whenIBlockAndDelete_thenIExpectGroupIdRetained() { + val groupId = insertPushGroup() + val threadId = insertThread(groupId) + val recipientId = SignalDatabase.recipients.getByGroupId(groupId).get() + + groupTable.setMember(groupId, false) + SignalDatabase.recipients.setBlocked(recipientId, true) + SignalDatabase.threads.deleteConversation(threadId) + + groupTable.clearGroupIfLeftAndDeleted(groupId) + + assertEquals(groupId, groupTable.getGroup(groupId).get().id) + assertFalse(groupTable.getGroup(groupId).get().hasV2GroupProperties) + } + + @Test + fun givenALeftGroupOnAMultiDeviceAccount_whenIDelete_thenIExpectGroupStubRetainedWithoutProperties() { + val groupId = insertPushGroup() + val threadId = insertThread(groupId) + + every { recipients.signalStore.account.isMultiDevice } returns true + groupTable.setMember(groupId, false) + threadTable.deleteConversation(threadId) + + groupTable.clearGroupIfLeftAndDeleted(groupId) + + val record = groupTable.getGroup(groupId) + assertTrue(record.isPresent) + assertFalse(record.get().hasV2GroupProperties) + assertTrue(SignalDatabase.recipients.getByGroupId(groupId).isPresent) + } + + @Test + fun givenALeftGroupOnASingleDevice_whenIDelete_thenIExpectRecipientRowAlsoDeleted() { + val groupId = insertPushGroup() + val threadId = insertThread(groupId) + + groupTable.setMember(groupId, false) + threadTable.deleteConversation(threadId) + + groupTable.clearGroupIfLeftAndDeleted(groupId) + + assertFalse(groupTable.getGroup(groupId).isPresent) + assertFalse(SignalDatabase.recipients.getByGroupId(groupId).isPresent) + } + + @Test + fun givenAMemberWithADeletedThread_whenILeaveLater_thenIExpectClearOnlyAfterTheSecondEvent() { + val groupId = insertPushGroup() + val threadId = insertThread(groupId) + + threadTable.deleteConversation(threadId) + groupTable.clearGroupIfLeftAndDeleted(groupId) + assertTrue("Still a member, so the deleted thread alone must not clear the group", groupTable.getGroup(groupId).isPresent) + + groupTable.setMember(groupId, false) + groupTable.clearGroupIfLeftAndDeleted(groupId) + assertFalse("Leaving after the thread was deleted should trigger the clear", groupTable.getGroup(groupId).isPresent) + } + + @Test + fun givenALeftGroup_whenIClearByRecipientId_thenIExpectGroupDeleted() { + val groupId = insertPushGroup() + val threadId = insertThread(groupId) + val recipientId = SignalDatabase.recipients.getByGroupId(groupId).get() + + groupTable.setMember(groupId, false) + threadTable.deleteConversation(threadId) + + groupTable.clearGroupIfLeftAndDeleted(recipientId) + + assertFalse(groupTable.getGroup(groupId).isPresent) + } + @Test fun givenAGroupV2_whenIGetGroupsContainingMember_thenIExpectGroup() { val groupId = insertPushGroup() @@ -344,4 +493,25 @@ class GroupTableTest { return groupTable.create(groupMasterKey, decryptedGroupState, null)!! } + + /** + * Guards [GroupTable.clearGroupRecipient]: every group column must be either blanked by [GroupTable.buildClearedGroupValues] + * or explicitly listed here as intentionally preserved. Adding a column without categorizing it fails this test so we don't silently leak it. + */ + @Test + fun buildClearedGroupValues_accountsForEveryColumn() { + val keptColumns = setOf( + GroupTable.ID, + GroupTable.RECIPIENT_ID, + GroupTable.GROUP_ID, + GroupTable.V2_MASTER_KEY + ) + + val clearedColumns = groupTable.buildClearedGroupValues().keySet() + val allColumns = SqlUtil.getAllColumns(groupTable.writableDatabase, GroupTable.TABLE_NAME) + val uncategorized = allColumns - clearedColumns - keptColumns + + assertThat(allColumns).isNotEmpty() + assertThat(uncategorized).isEmpty() + } } diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/RecipientTableTest.kt b/app/src/test/java/org/thoughtcrime/securesms/database/RecipientTableTest.kt index 38589fe7a6..bf08ebc799 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/database/RecipientTableTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/database/RecipientTableTest.kt @@ -6,6 +6,9 @@ package org.thoughtcrime.securesms.database import android.app.Application +import assertk.assertThat +import assertk.assertions.isEmpty +import assertk.assertions.isNotEmpty import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotEquals @@ -19,6 +22,7 @@ import org.robolectric.annotation.Config import org.signal.core.models.ServiceId.ACI import org.signal.core.models.ServiceId.PNI import org.signal.core.util.CursorUtil +import org.signal.core.util.SqlUtil import org.thoughtcrime.securesms.profiles.ProfileName import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.testutil.RecipientTestRule @@ -204,6 +208,28 @@ class RecipientTableTest { ) } + /** + * Guards [RecipientTable.clearGroupRecipient]: every recipient column must be either blanked by [RecipientTable.buildClearedGroupRecipientValues] + * or explicitly listed here as intentionally preserved. Adding a column without categorizing it fails this test so we don't silently leak it. + */ + @Test + fun buildClearedGroupRecipientValues_accountsForEveryColumn() { + val keptColumns = setOf( + RecipientTable.ID, + RecipientTable.GROUP_ID, + RecipientTable.TYPE, + RecipientTable.BLOCKED, + RecipientTable.STORAGE_SERVICE_ID + ) + + val clearedColumns = SignalDatabase.recipients.buildClearedGroupRecipientValues().keySet() + val allColumns = SqlUtil.getAllColumns(SignalDatabase.recipients.writableDatabase, RecipientTable.TABLE_NAME) + val uncategorized = allColumns - clearedColumns - keptColumns + + assertThat(allColumns).isNotEmpty() + assertThat(uncategorized).isEmpty() + } + companion object { val ACI_A = ACI.from(UUID.fromString("aaaa0000-5a76-47fa-a98a-7e72c948a82e")) val PNI_A = PNI.from(UUID.fromString("aaaa1111-c960-4f6c-8385-671ad2ffb999")) diff --git a/app/src/test/java/org/thoughtcrime/securesms/jobs/GroupDeletedBackfillWorkerJobTest.kt b/app/src/test/java/org/thoughtcrime/securesms/jobs/GroupDeletedBackfillWorkerJobTest.kt new file mode 100644 index 0000000000..6d720f4661 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/jobs/GroupDeletedBackfillWorkerJobTest.kt @@ -0,0 +1,126 @@ +package org.thoughtcrime.securesms.jobs + +import android.app.Application +import io.mockk.every +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.thoughtcrime.securesms.database.DraftTable.Draft +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.MessageId +import org.thoughtcrime.securesms.database.model.ReactionRecord +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.testutil.RecipientTestRule + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, application = Application::class) +class GroupDeletedBackfillWorkerJobTest { + + @get:Rule + val recipients = RecipientTestRule() + + @Test + fun run_clearsLeftGroupWithNoActiveThread() { + val left = recipients.createGroup(recipients.createRecipient("")) + SignalDatabase.threads.getOrCreateThreadIdFor(left.recipientId, isGroup = true) + SignalDatabase.groups.setMember(left.groupId, false) + + GroupDeletedBackfillWorkerJob().run() + + assertFalse(SignalDatabase.groups.getGroup(left.groupId).isPresent) + } + + @Test + fun run_clearsTerminatedGroupWithNoActiveThread() { + val terminated = recipients.createGroup(recipients.createRecipient("")) + SignalDatabase.groups.setTerminatedBy(terminated.groupId, recipients.self) + + GroupDeletedBackfillWorkerJob().run() + + assertFalse(SignalDatabase.groups.getGroup(terminated.groupId).isPresent) + } + + @Test + fun run_leavesActiveGroupUntouched() { + val active = recipients.createGroup(recipients.createRecipient("")) + SignalDatabase.threads.getOrCreateThreadIdFor(active.recipientId, isGroup = true) + + GroupDeletedBackfillWorkerJob().run() + + val activeGroup = SignalDatabase.groups.getGroup(active.groupId) + assertTrue(activeGroup.isPresent) + assertTrue(activeGroup.get().isActive) + } + + @Test + fun run_leavesLeftGroupWithActiveThreadUntouched() { + val group = recipients.createGroup(recipients.createRecipient("")) + val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(group.recipientId, isGroup = true) + SignalDatabase.threads.markAsActiveEarly(threadId) + SignalDatabase.groups.setMember(group.groupId, false) + + GroupDeletedBackfillWorkerJob().run() + + val groupRecord = SignalDatabase.groups.getGroup(group.groupId) + assertTrue(groupRecord.isPresent) + assertTrue(groupRecord.get().hasV2GroupProperties) + } + + @Test + fun run_triggersRecipientIdDatabaseReferenceCascade_forClearedGroupsRecipientOnly() { + val clearedGroup = recipients.createGroup(recipients.createRecipient("")) + SignalDatabase.groups.setMember(clearedGroup.groupId, false) + + val keptGroup = recipients.createGroup(recipients.createRecipient("")) + SignalDatabase.groups.setMember(keptGroup.groupId, true) + + insertReaction(clearedGroup.recipientId) + insertReaction(keptGroup.recipientId) + + GroupDeletedBackfillWorkerJob().run() + + assertFalse(SignalDatabase.reactions.hasReactions(MessageId(clearedGroup.recipientId.toLong()))) + assertTrue(SignalDatabase.reactions.hasReactions(MessageId(keptGroup.recipientId.toLong()))) + } + + @Test + fun run_triggersThreadIdDatabaseReferenceCascade_forClearedGroupsThreadOnly() { + val cleared = recipients.createGroup(recipients.createRecipient("")) + val clearedThreadId = SignalDatabase.threads.getOrCreateThreadIdFor(cleared.recipientId, isGroup = true) + SignalDatabase.groups.setMember(cleared.groupId, false) + + val keepRecipientId = recipients.createGroup(recipients.createRecipient("")) + val keepThreadId = SignalDatabase.threads.getOrCreateThreadIdFor(keepRecipientId.recipientId, isGroup = true) + + SignalDatabase.drafts.replaceDrafts(clearedThreadId, listOf(Draft(type = Draft.TEXT, value = "text"))) + SignalDatabase.drafts.replaceDrafts(keepThreadId, listOf(Draft(type = Draft.TEXT, value = "text"))) + + GroupDeletedBackfillWorkerJob().run() + + assertEquals(0, SignalDatabase.drafts.getDrafts(clearedThreadId).count()) + assertEquals(1, SignalDatabase.drafts.getDrafts(keepThreadId).count()) + } + + @Test + fun run_keepsStubForMultiDeviceLeftGroup() { + val group = recipients.createGroup(recipients.createRecipient("")) + SignalDatabase.groups.setMember(group.groupId, false) + + every { recipients.signalStore.account.isMultiDevice } returns true + + GroupDeletedBackfillWorkerJob().run() + + val record = SignalDatabase.groups.getGroup(group.groupId) + assertTrue(record.isPresent) + assertFalse(record.get().hasV2GroupProperties) + } + + private fun insertReaction(id: RecipientId) { + SignalDatabase.reactions.addReaction(MessageId(id.toLong()), ReactionRecord(emoji = "👍", author = id, dateSent = 1L, dateReceived = 1L)) + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/testutil/SignalDatabaseRule.kt b/app/src/test/java/org/thoughtcrime/securesms/testutil/SignalDatabaseRule.kt index d9e2f8f5dd..30190d7076 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/testutil/SignalDatabaseRule.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/testutil/SignalDatabaseRule.kt @@ -10,6 +10,7 @@ import io.mockk.every import io.mockk.mockkObject import io.mockk.unmockkObject import org.junit.rules.ExternalResource +import org.thoughtcrime.securesms.database.DatabaseTable import org.thoughtcrime.securesms.database.RemappedRecordsTestHelper import org.thoughtcrime.securesms.database.SQLiteDatabase import org.thoughtcrime.securesms.database.SearchTable @@ -31,6 +32,7 @@ class SignalDatabaseRule : ExternalResource() { override fun before() { RecipientId.clearCache() RemappedRecordsTestHelper.resetInstance() + DatabaseTable.clearTableReferencesForTests() signalDatabase = inMemorySignalDatabase() @@ -44,6 +46,7 @@ class SignalDatabaseRule : ExternalResource() { signalDatabase.close() RecipientId.clearCache() RemappedRecordsTestHelper.resetInstance() + DatabaseTable.clearTableReferencesForTests() } companion object { diff --git a/app/src/test/java/org/thoughtcrime/securesms/util/GroupUtilTest.kt b/app/src/test/java/org/thoughtcrime/securesms/util/GroupUtilTest.kt new file mode 100644 index 0000000000..cc657948d3 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/util/GroupUtilTest.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.util + +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import io.mockk.every +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.testutil.RecipientTestRule +import org.thoughtcrime.securesms.transport.UndeliverableMessageException +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, application = Application::class) +class GroupUtilTest { + + @get:Rule + val recipients = RecipientTestRule() + + @Test + fun setDataMessageGroupContext_attachesContextForHealthyGroup() { + val group = recipients.createGroup(recipients.createRecipient("Member One")) + val builder = SignalServiceDataMessage.newBuilder().withTimestamp(1L) + + GroupUtil.setDataMessageGroupContext(ApplicationProvider.getApplicationContext(), builder, group.groupId.requirePush()) + + assertTrue(builder.build().groupContext.isPresent) + } + + @Test(expected = UndeliverableMessageException::class) + fun setDataMessageGroupContext_throwsWhenGroupPropertiesMissing() { + val group = recipients.createGroup(recipients.createRecipient("Member One")) + + // Multi-device keeps the group stub but strips its V2 properties once left and deleted. + every { recipients.signalStore.account.isMultiDevice } returns true + SignalDatabase.groups.setMember(group.groupId, false) + SignalDatabase.groups.clearGroupIfLeftAndDeleted(group.groupId) + + val builder = SignalServiceDataMessage.newBuilder().withTimestamp(1L) + GroupUtil.setDataMessageGroupContext(ApplicationProvider.getApplicationContext(), builder, group.groupId.requirePush()) + } +} diff --git a/core/util/src/main/java/org/signal/core/util/SqlUtil.kt b/core/util/src/main/java/org/signal/core/util/SqlUtil.kt index 4b26090c29..3d2b3c4467 100644 --- a/core/util/src/main/java/org/signal/core/util/SqlUtil.kt +++ b/core/util/src/main/java/org/signal/core/util/SqlUtil.kt @@ -144,6 +144,18 @@ object SqlUtil { return false } + @JvmStatic + fun getAllColumns(db: SupportSQLiteDatabase, table: String): Set { + val columns = mutableSetOf() + db.query("PRAGMA table_info($table)", arrayOf()).use { cursor -> + val nameColumnIndex = cursor.getColumnIndexOrThrow("name") + while (cursor.moveToNext()) { + columns += cursor.getString(nameColumnIndex) + } + } + return columns + } + @JvmStatic fun buildArgs(vararg objects: Any?): Array { return objects.map {