mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-08-04 12:24:00 +01:00
Improve group deletion.
Co-authored-by: Cody Henthorne <cody@signal.org>
This commit is contained in:
@@ -1522,7 +1522,7 @@ object BackupRepository {
|
||||
val jobs = mutableListOf<Job>()
|
||||
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
|
||||
|
||||
+1
-1
@@ -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<RecipientId> = decryptedGroup.pendingMembers
|
||||
.map { m -> m.serviceIdBytes }
|
||||
|
||||
+1
-1
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -108,7 +108,7 @@ public final class ShowAdminsBottomSheetDialog extends BottomSheetDialogFragment
|
||||
@WorkerThread
|
||||
private static @NonNull List<GroupMemberEntry> 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();
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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<ReviewRecipient> = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(group.recipientId)
|
||||
|
||||
|
||||
+1
-1
@@ -153,7 +153,7 @@ class ConversationViewModel(
|
||||
|
||||
val groupMemberServiceIds: Observable<List<ServiceId>> = recipientRepository
|
||||
.groupRecord
|
||||
.filter { it.isPresent && it.get().isV2Group }
|
||||
.filter { it.isPresent && it.get().hasV2GroupProperties }
|
||||
.map { it.get().requireV2GroupProperties().getMemberServiceIds() }
|
||||
.distinctUntilChanged()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
|
||||
+1
-1
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+21
-14
@@ -1192,11 +1192,11 @@ public class ConversationListFragment extends MainFragment implements Conversati
|
||||
}
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
private void handleDelete(@NonNull Collection<Long> ids) {
|
||||
private void handleDelete(@NonNull Collection<Long> 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())));
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<Long> threadIds) {
|
||||
AppDependencies.getDatabaseObserver().notifyConversationListeners(threadIds);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -113,6 +113,10 @@ class DraftTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
|
||||
.run()
|
||||
}
|
||||
|
||||
override fun onDeletedGroupThread(threadId: Long) {
|
||||
clearDrafts(threadId)
|
||||
}
|
||||
|
||||
private fun List<Draft>.asDrafts(): Drafts {
|
||||
return Drafts(this)
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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<RecipientId> {
|
||||
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<Recipient> {
|
||||
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<GroupRecord> = getGroup(groupId)
|
||||
|
||||
if (groupRecord.isPresent && groupRecord.get().isV2Group) {
|
||||
if (groupRecord.isPresent && groupRecord.get().hasV2GroupProperties) {
|
||||
val pendingMembers: List<DecryptedPendingMember> = 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<RecipientId> = 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<RecipientId> = if (existingGroup.isPresent && existingGroup.get().isV2Group) {
|
||||
val addedMembers: Collection<RecipientId> = if (existingGroup.isPresent && existingGroup.get().hasV2GroupProperties) {
|
||||
val change = GroupChangeReconstruct.reconstructGroupChange(existingGroup.get().requireV2GroupProperties().decryptedGroup, decryptedGroup)
|
||||
val removed: List<ServiceId> = 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<GroupRecord> {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Int>)
|
||||
}
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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<ReviewRecipient>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+9
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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)
|
||||
|
||||
+2
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<String> {
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -6,4 +6,5 @@ package org.thoughtcrime.securesms.database;
|
||||
*/
|
||||
interface ThreadIdDatabaseReference {
|
||||
void remapThread(long fromId, long toId);
|
||||
void onDeletedGroupThread(long threadId);
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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<RecipientId> 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())
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<UUID, UuidCiphertext> 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<RecipientId> newMembers, @NonNull Set<ServiceId> bannedMembers)
|
||||
@NonNull GroupManager.GroupActionResult addMembers(@NonNull Collection<RecipientId> 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<DecryptedMember> selfMember = DecryptedGroupUtil.findMemberByAci(decryptedGroup.members, selfAci);
|
||||
Optional<DecryptedPendingMember> aciPendingMember = DecryptedGroupUtil.findPendingByServiceId(decryptedGroup.pendingMembers, selfAci);
|
||||
Optional<DecryptedPendingMember> 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<DecryptedMember> 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<DecryptedMember> 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
|
||||
|
||||
@@ -62,8 +62,11 @@ public final class LiveGroup {
|
||||
this.requestingMembers = mapToRequestingMembers(this.groupRecord);
|
||||
|
||||
if (groupId.isV2()) {
|
||||
LiveData<GroupTable.V2GroupProperties> v2Properties = Transformations.map(this.groupRecord, GroupRecord::requireV2GroupProperties);
|
||||
LiveData<GroupTable.V2GroupProperties> 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<List<GroupMemberEntry.RequestingMember>> mapToRequestingMembers(@NonNull LiveData<GroupRecord> 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<Set<ServiceId>> 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<Boolean> isActive() {
|
||||
@@ -164,12 +167,12 @@ public final class LiveGroup {
|
||||
}
|
||||
|
||||
public LiveData<Integer> 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<Integer> getPendingAndRequestingMemberCount() {
|
||||
return Transformations.map(groupRecord, g -> {
|
||||
if (g.isV2Group()) {
|
||||
if (g.getHasV2GroupProperties()) {
|
||||
DecryptedGroup decryptedGroup = g.requireV2GroupProperties().getDecryptedGroup();
|
||||
|
||||
return decryptedGroup.pendingMembers.size() + decryptedGroup.requestingMembers.size();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
+10
-2
@@ -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<InviteeResult> 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<DecryptedPendingMember> pendingMembersList = decryptedGroup.pendingMembers;
|
||||
List<SinglePendingMemberInvitedByYou> byMe = new ArrayList<>(pendingMembersList.size());
|
||||
|
||||
+13
-3
@@ -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<GroupRecord> = 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 }) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ public final class ForceUpdateGroupV2Job extends BaseJob {
|
||||
SignalExecutors.BOUNDED.execute(() -> {
|
||||
Optional<GroupRecord> 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));
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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<Recipient> deliver(@NonNull GroupId groupId, @NonNull List<Recipient> destinations)
|
||||
throws IOException, UntrustedIdentityException, NoSessionException
|
||||
throws IOException, UntrustedIdentityException, NoSessionException, UndeliverableMessageException
|
||||
{
|
||||
SignalServiceDataMessage.Builder dataMessageBuilder = SignalServiceDataMessage.newBuilder()
|
||||
.withTimestamp(System.currentTimeMillis())
|
||||
|
||||
@@ -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<GroupDeletedBackfillWorkerJob> {
|
||||
override fun create(parameters: Parameters, serializedData: ByteArray?): GroupDeletedBackfillWorkerJob {
|
||||
return GroupDeletedBackfillWorkerJob(parameters)
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -118,7 +118,7 @@ public final class GroupV2UpdateSelfProfileKeyJob extends BaseJob {
|
||||
|
||||
for (GroupId.V2 id : SignalDatabase.groups().getAllGroupV2Ids()) {
|
||||
Optional<GroupRecord> 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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
+5
@@ -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);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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<Recipient> deliver(@NonNull RecipientRecord conversationRecipient, @NonNull List<Recipient> destinations, @NonNull Recipient targetAuthor, long targetSentTimestamp)
|
||||
throws IOException, UntrustedIdentityException, NoSessionException
|
||||
throws IOException, UntrustedIdentityException, NoSessionException, UndeliverableMessageException
|
||||
{
|
||||
SignalServiceDataMessage.Builder dataMessageBuilder = SignalServiceDataMessage.newBuilder()
|
||||
.withTimestamp(System.currentTimeMillis())
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@ public final class MessageRequestRepository {
|
||||
|
||||
if (groupRecord.isPresent()) {
|
||||
List<Recipient> 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<Recipient> membersPreview = recipients.stream().filter(r -> !r.isSelf()).limit(MAX_MEMBER_NAMES).collect(Collectors.toList());
|
||||
DecryptedGroup decryptedGroup = groupRecord.get().requireV2GroupProperties().getDecryptedGroup();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -262,7 +262,7 @@ public final class GroupSendUtil {
|
||||
|
||||
RecipientData recipients = new RecipientData(context, registeredTargets, isStorySend);
|
||||
Optional<GroupRecord> 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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+35
@@ -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<GroupDeletedBackfillMigrationJob> {
|
||||
override fun create(parameters: Parameters, serializedData: ByteArray?): GroupDeletedBackfillMigrationJob {
|
||||
return GroupDeletedBackfillMigrationJob(parameters)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -304,6 +304,7 @@ class Recipient(
|
||||
val participantAcis: List<ServiceId>
|
||||
get() {
|
||||
return groupRecord
|
||||
.filter { it.hasV2GroupProperties }
|
||||
.map { it.requireV2GroupProperties().getMemberServiceIds().toImmutableList() }
|
||||
.orElse(emptyList<ServiceId>().toImmutableList())
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
}
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+5
-4
@@ -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> 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));
|
||||
|
||||
+19
-9
@@ -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<Void, GroupChangeFailureReason> 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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -762,17 +762,28 @@
|
||||
<!-- Notice on chat list when no unread chats are available, centered on display -->
|
||||
<string name="ConversationListFragment__no_unread_chats">No unread chats</string>
|
||||
<plurals name="ConversationListFragment_delete_selected_conversations">
|
||||
<item quantity="one">Delete selected chat?</item>
|
||||
<item quantity="other">Delete selected chats?</item>
|
||||
<item quantity="one">Delete message history?</item>
|
||||
<item quantity="other">Delete message histories?</item>
|
||||
</plurals>
|
||||
<!-- Dialog message shown when deleting chats, none of which are groups -->
|
||||
<plurals name="ConversationListFragment_this_will_permanently_delete_all_n_selected_conversations">
|
||||
<item quantity="one">This will permanently delete the selected chat.</item>
|
||||
<item quantity="other">This will permanently delete all %1$d selected chats.</item>
|
||||
<item quantity="one">All messages in this chat will be deleted.</item>
|
||||
<item quantity="other">All messages in these chats will be deleted.</item>
|
||||
</plurals>
|
||||
<!-- Dialog message shown when deleting one to many conversations from the chat list and the user has a linked device -->
|
||||
<!-- Dialog message shown when deleting chats, with at least one group -->
|
||||
<plurals name="ConversationListFragment_this_will_permanently_delete_all_n_selected_conversations_group">
|
||||
<item quantity="one">All messages in this chat will be deleted. You will still be a member of this group.</item>
|
||||
<item quantity="other">All messages in these chats will be deleted. You will still be a member of any groups you have not left yet.</item>
|
||||
</plurals>
|
||||
<!-- Dialog message shown when deleting chats, none of which are groups, and the user has a linked device -->
|
||||
<plurals name="ConversationListFragment_this_will_permanently_delete_all_n_selected_conversations_linked_device">
|
||||
<item quantity="one">This will permanently delete the selected chat from all your devices.</item>
|
||||
<item quantity="other">This will permanently delete all %1$d selected chats from all your devices.</item>
|
||||
<item quantity="one">All messages in this chat will be deleted from all your devices.</item>
|
||||
<item quantity="other">All messages in these chats will be deleted from all your devices.</item>
|
||||
</plurals>
|
||||
<!-- Dialog message shown when deleting chats with at least one group, and the user has a linked device -->
|
||||
<plurals name="ConversationListFragment_this_will_permanently_delete_all_n_selected_conversations_linked_device_group">
|
||||
<item quantity="one">All messages in this chat will be deleted from all your devices. You will still be a member of this group.</item>
|
||||
<item quantity="other">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.</item>
|
||||
</plurals>
|
||||
<string name="ConversationListFragment_deleting">Deleting</string>
|
||||
<plurals name="ConversationListFragment_deleting_selected_conversations">
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
|
||||
+126
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -144,6 +144,18 @@ object SqlUtil {
|
||||
return false
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getAllColumns(db: SupportSQLiteDatabase, table: String): Set<String> {
|
||||
val columns = mutableSetOf<String>()
|
||||
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<String> {
|
||||
return objects.map {
|
||||
|
||||
Reference in New Issue
Block a user