Add additional group terminate checks.

This commit is contained in:
Cody Henthorne
2026-03-24 16:12:45 -04:00
parent d97bde3959
commit c81f40eb74
20 changed files with 201 additions and 47 deletions

View File

@@ -101,6 +101,19 @@ public class TypingStatusRepository {
return threadsNotifier;
}
public synchronized void stopAllTypingForThread(long threadId) {
Set<Typist> typists = typistMap.remove(threadId);
if (typists != null) {
for (Typist typist : typists) {
Runnable timer = timers.remove(typist);
if (timer != null) {
ThreadUtil.cancelRunnableOnMain(timer);
}
}
notifyThread(threadId, Collections.emptySet(), false);
}
}
public synchronized void clear() {
TypingState empty = new TypingState(Collections.emptyList(), false);
for (MutableLiveData<TypingState> notifier : notifiers.values()) {

View File

@@ -1514,10 +1514,10 @@ class ConversationFragment :
when {
inputReadyState.isClientExpired || inputReadyState.isUnauthorized -> disabledInputView.showAsExpiredOrUnauthorized(inputReadyState.isClientExpired, inputReadyState.isUnauthorized)
args.isIncognito -> disabledInputView.showAsIncognito()
!inputReadyState.messageRequestState.isAccepted -> disabledInputView.showAsMessageRequest(inputReadyState.conversationRecipient, inputReadyState.messageRequestState)
inputReadyState.isTerminatedGroup -> disabledInputView.showAsTerminatedGroup()
inputReadyState.isActiveGroup == false -> disabledInputView.showAsNoLongerAMember()
!inputReadyState.messageRequestState.isAccepted -> disabledInputView.showAsMessageRequest(inputReadyState.conversationRecipient, inputReadyState.messageRequestState)
inputReadyState.isRequestingMember == true -> disabledInputView.showAsRequestingMember()
inputReadyState.isActiveGroup == false -> disabledInputView.showAsNoLongerAMember()
inputReadyState.isAnnouncementGroup == true && inputReadyState.isAdmin == false -> disabledInputView.showAsAnnouncementGroupAdminsOnly()
inputReadyState.conversationRecipient.isReleaseNotes -> disabledInputView.showAsReleaseNotesChannel(inputReadyState.conversationRecipient)
inputReadyState.shouldShowInviteToSignal() -> disabledInputView.showAsInviteToSignal(requireContext(), inputReadyState.conversationRecipient, inputReadyState.threadContainsSms)
@@ -3609,7 +3609,14 @@ class ConversationFragment :
}
override fun onInviteFriendsToGroupClicked(groupId: GroupId.V2) {
GroupLinkInviteFriendsBottomSheetDialogFragment.show(requireActivity().supportFragmentManager, groupId)
if (conversationGroupViewModel.groupRecordSnapshot?.isTerminated == true) {
MaterialAlertDialogBuilder(requireContext())
.setMessage(R.string.conversation_activity__group_action_not_allowed_group_ended)
.setPositiveButton(android.R.string.ok) { d, _ -> d.dismiss() }
.show()
} else {
GroupLinkInviteFriendsBottomSheetDialogFragment.show(requireActivity().supportFragmentManager, groupId)
}
}
@SuppressLint("NotifyDataSetChanged")

View File

@@ -406,7 +406,7 @@ class ConversationViewModel(
val pendingGroupJoinFlow: Flow<PendingGroupJoinRequestsBanner> = groupRecordFlow
.map {
PendingGroupJoinRequestsBanner(
suggestionsSize = it.actionableRequestingMembersCount,
suggestionsSize = if (it.isTerminated) 0 else it.actionableRequestingMembersCount,
onViewClicked = groupJoinClickListener
)
}

View File

@@ -82,7 +82,7 @@ private fun TerminatedGroupSheetContent(adminName: String?, onOkClick: () -> Uni
.defaultMinSize(minWidth = 220.dp)
.padding(bottom = 24.dp)
) {
Text(text = stringResource(R.string.TerminatedGroupBottomSheet__okay))
Text(text = stringResource(android.R.string.ok))
}
}
}

View File

@@ -787,11 +787,11 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
notifyConversationListListeners()
}
fun update(groupMasterKey: GroupMasterKey, decryptedGroup: DecryptedGroup, groupSendEndorsements: ReceivedGroupSendEndorsements?) {
update(GroupId.v2(groupMasterKey), decryptedGroup, groupSendEndorsements)
fun update(groupMasterKey: GroupMasterKey, decryptedGroup: DecryptedGroup, groupSendEndorsements: ReceivedGroupSendEndorsements?, terminatorRecipientId: RecipientId? = null) {
update(GroupId.v2(groupMasterKey), decryptedGroup, groupSendEndorsements, terminatorRecipientId)
}
fun update(groupId: GroupId.V2, decryptedGroup: DecryptedGroup, receivedGroupSendEndorsements: ReceivedGroupSendEndorsements?) {
fun update(groupId: GroupId.V2, decryptedGroup: DecryptedGroup, receivedGroupSendEndorsements: ReceivedGroupSendEndorsements?, terminatorRecipientId: RecipientId? = null) {
val groupRecipientId: RecipientId = recipients.getOrInsertFromGroupId(groupId)
val existingGroup: Optional<GroupRecord> = getGroup(groupId)
val title: String = decryptedGroup.title
@@ -801,7 +801,7 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
contentValues.put(V2_REVISION, decryptedGroup.revision)
contentValues.put(V2_DECRYPTED_GROUP, decryptedGroup.encode())
contentValues.put(IS_MEMBER, if (isGroupMember(decryptedGroup)) 1 else 0)
contentValues.put(TERMINATED_BY, if (decryptedGroup.terminated) -1 else 0)
contentValues.put(TERMINATED_BY, terminatorRecipientId?.toLong() ?: if (decryptedGroup.terminated) -1 else 0)
if (receivedGroupSendEndorsements != null) {
contentValues.put(GROUP_SEND_ENDORSEMENTS_EXPIRATION, receivedGroupSendEndorsements.expirationMs)

View File

@@ -744,7 +744,8 @@ final class GroupManagerV2 {
throw new IOException(e);
}
groupDatabase.update(groupId, decryptedGroupState, groupsV2Operations.forGroup(groupSecretParams).receiveGroupSendEndorsements(selfAci, decryptedGroupState, changeResponse.group_send_endorsements_response));
RecipientId terminatorRecipientId = (decryptedGroupState.terminated && !previousGroupState.terminated) ? Recipient.self().getId() : null;
groupDatabase.update(groupId, decryptedGroupState, groupsV2Operations.forGroup(groupSecretParams).receiveGroupSendEndorsements(selfAci, decryptedGroupState, changeResponse.group_send_endorsements_response), terminatorRecipientId);
GroupMutation groupMutation = new GroupMutation(previousGroupState, decryptedChange, decryptedGroupState);
RecipientAndThread recipientAndThread = sendGroupUpdateHelper.sendGroupUpdate(groupMasterKey, groupMutation, signedGroupChange, sendToMembers);
@@ -986,7 +987,7 @@ final class GroupManagerV2 {
}
} else if (groupAlreadyExists && requestToJoin) {
Log.i(TAG, "Group already exists, but we are requesting to join, updating with new placeholder, alreadyPending: " + isAlreadyPendingApproval);
groupDatabase.update(groupMasterKey, decryptedGroup, null);
groupDatabase.update(groupMasterKey, decryptedGroup, null, null);
}
RecipientId groupRecipientId = SignalDatabase.recipients().getOrInsertFromGroupId(groupId);
@@ -1250,7 +1251,7 @@ final class GroupManagerV2 {
DecryptedGroupChange decryptedChange = groupOperations.decryptChange(signedGroupChange, DecryptChangeVerificationMode.alreadyTrusted()).get();
DecryptedGroup newGroup = DecryptedGroupUtil.applyWithoutRevisionCheck(decryptedGroup, decryptedChange);
groupDatabase.update(groupId, resetRevision(newGroup, decryptedGroup.revision), null);
groupDatabase.update(groupId, resetRevision(newGroup, decryptedGroup.revision), null, null);
sendGroupUpdateHelper.sendGroupUpdate(groupMasterKey, new GroupMutation(decryptedGroup, decryptedChange, newGroup), signedGroupChange, false);
} catch (VerificationFailedException | InvalidGroupStateException | NotAbleToApplyGroupV2ChangeException e) {

View File

@@ -8,6 +8,7 @@ import org.thoughtcrime.securesms.groups.GroupChangeBusyException;
import org.thoughtcrime.securesms.groups.GroupInsufficientRightsException;
import org.thoughtcrime.securesms.groups.GroupNotAMemberException;
import org.thoughtcrime.securesms.groups.MembershipNotSuitableForV2Exception;
import org.whispersystems.signalservice.internal.push.exceptions.GroupTerminatedException;
import java.io.IOException;
@@ -18,11 +19,13 @@ public enum GroupChangeFailureReason {
NOT_A_MEMBER,
BUSY,
NETWORK,
GROUP_TERMINATED,
OTHER;
@SuppressLint("SuspiciousIndentation")
public static @NonNull GroupChangeFailureReason fromException(@NonNull Throwable e) {
if (e instanceof MembershipNotSuitableForV2Exception) return GroupChangeFailureReason.NOT_GV2_CAPABLE;
if (e instanceof GroupTerminatedException) return GroupChangeFailureReason.GROUP_TERMINATED;
if (e instanceof IOException) return GroupChangeFailureReason.NETWORK;
if (e instanceof GroupNotAMemberException) return GroupChangeFailureReason.NOT_A_MEMBER;
if (e instanceof GroupChangeBusyException) return GroupChangeFailureReason.BUSY;

View File

@@ -21,6 +21,7 @@ public final class GroupErrors {
case NOT_A_MEMBER : return R.string.ManageGroupActivity_youre_not_a_member_of_the_group;
case BUSY : return R.string.ManageGroupActivity_failed_to_update_the_group_please_retry_later;
case NETWORK : return R.string.ManageGroupActivity_failed_to_update_the_group_due_to_a_network_error_please_retry_later;
case GROUP_TERMINATED : return R.string.MessageRecord_the_group_was_terminated;
default : return R.string.ManageGroupActivity_failed_to_update_the_group;
}
}

View File

@@ -3,5 +3,6 @@ package org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining;
enum FetchGroupDetailsError {
GroupLinkNotActive,
BannedFromGroup,
NetworkError
NetworkError,
GroupTerminated
}

View File

@@ -175,6 +175,10 @@ public final class GroupJoinBottomSheetDialogFragment extends BottomSheetDialogF
groupName.setText(R.string.GroupJoinBottomSheetDialogFragment_link_error);
groupDetails.setText(R.string.GroupJoinBottomSheetDialogFragment_joining_via_this_link_failed_try_joining_again_later);
break;
case GroupTerminated:
groupName.setText(R.string.GroupJoinBottomSheetDialogFragment_cant_join_group);
groupDetails.setText(R.string.GroupJoinBottomSheetDialogFragment_this_group_has_been_ended);
break;
}
}
@@ -184,6 +188,7 @@ public final class GroupJoinBottomSheetDialogFragment extends BottomSheetDialogF
case BANNED : return getString(R.string.GroupJoinBottomSheetDialogFragment_you_cant_join_this_group_via_the_group_link_because_an_admin_removed_you);
case NETWORK_ERROR : return getString(R.string.GroupJoinBottomSheetDialogFragment_encountered_a_network_error);
case LIMIT_REACHED : return getString(R.string.GroupJoinBottomSheetDialogFragment_group_limit_reached);
case GROUP_TERMINATED : return getString(R.string.GroupJoinBottomSheetDialogFragment_this_group_has_been_ended);
default : return getString(R.string.GroupJoinBottomSheetDialogFragment_unable_to_join_group_please_try_again_later);
}
}

View File

@@ -19,6 +19,7 @@ import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob;
import org.thoughtcrime.securesms.util.AsynchronousCallback;
import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException;
import org.whispersystems.signalservice.internal.push.exceptions.GroupPatchNotAcceptedException;
import org.whispersystems.signalservice.internal.push.exceptions.GroupTerminatedException;
import java.io.IOException;
@@ -38,6 +39,8 @@ final class GroupJoinRepository {
SignalExecutors.UNBOUNDED.execute(() -> {
try {
callback.onComplete(getGroupDetails());
} catch (GroupTerminatedException e) {
callback.onError(FetchGroupDetailsError.GroupTerminated);
} catch (IOException e) {
callback.onError(FetchGroupDetailsError.NetworkError);
} catch (GroupLinkNotActiveException e) {
@@ -60,6 +63,9 @@ final class GroupJoinRepository {
groupDetails.getAvatarBytes());
callback.onComplete(new JoinGroupSuccess(groupActionResult.getGroupRecipient(), groupActionResult.getThreadId()));
} catch (GroupTerminatedException e) {
Log.w(TAG, "Group is terminated", e);
callback.onError(JoinGroupError.GROUP_TERMINATED);
} catch (IOException e) {
Log.w(TAG, "Network error", e);
callback.onError(JoinGroupError.NETWORK_ERROR);

View File

@@ -7,4 +7,5 @@ enum JoinGroupError {
FAILED,
LIMIT_REACHED,
NETWORK_ERROR,
GROUP_TERMINATED,
}

View File

@@ -41,10 +41,12 @@ import org.thoughtcrime.securesms.mms.MmsException
import org.thoughtcrime.securesms.mms.OutgoingMessage
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil
import org.whispersystems.signalservice.api.groupsv2.GroupChangeReconstruct
import org.whispersystems.signalservice.api.groupsv2.GroupHistoryPage
import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException
import org.whispersystems.signalservice.api.groupsv2.InvalidGroupStateException
import org.whispersystems.signalservice.api.groupsv2.NotAbleToApplyGroupV2ChangeException
import org.whispersystems.signalservice.api.groupsv2.ReceivedGroupSendEndorsements
@@ -52,6 +54,7 @@ import org.whispersystems.signalservice.api.groupsv2.getChangedFields
import org.whispersystems.signalservice.api.groupsv2.isSilent
import org.whispersystems.signalservice.api.push.ServiceIds
import org.whispersystems.signalservice.internal.push.exceptions.GroupNotFoundException
import org.whispersystems.signalservice.internal.push.exceptions.GroupTerminatedException
import org.whispersystems.signalservice.internal.push.exceptions.NotInGroupException
import java.io.IOException
import java.util.Optional
@@ -214,6 +217,21 @@ class GroupsV2StateProcessor private constructor(
if (currentLocalState != null && DecryptedGroupUtil.isPendingOrRequesting(currentLocalState, serviceIds)) {
Log.w(TAG, "$logPrefix Unable to query server for group. Server says we're not in group, but we think we are a pending or requesting member")
try {
groupsApi.getGroupJoinInfo(groupSecretParams, Optional.empty(), groupsV2Authorization.getAuthorizationForToday(serviceIds, groupSecretParams))
} catch (e: GroupTerminatedException) {
Log.i(TAG, "$logPrefix Group was terminated while join request was pending, marking locally")
profileAndMessageHelper.markTerminatedLocally()
} catch (e: GroupLinkNotActiveException) {
if (e.reason == GroupLinkNotActiveException.Reason.BANNED) {
Log.i(TAG, "$logPrefix Join request was rejected (banned) while pending, marking locally")
profileAndMessageHelper.markJoinRequestRejectedLocally()
} else {
Log.i(TAG, "$logPrefix Group link not active while checking pending join for group termination")
}
} catch (e: IOException) {
Log.w(TAG, "$logPrefix Network error while checking if group terminated", e)
}
} else {
Log.w(TAG, "$logPrefix Unable to query server for group $groupId server says we're not in group, we agree, inserting leave message")
profileAndMessageHelper.leaveGroupLocally(serviceIds)
@@ -525,20 +543,20 @@ class GroupsV2StateProcessor private constructor(
Log.i(TAG, "$logPrefix Local state (revision: ${currentLocalState?.revision}) does not match, updating to ${updatedGroupState.revision}")
}
saveGroupState(groupStateDiff, updatedGroupState, groupSendEndorsements)
if (updatedGroupState.terminated && (currentLocalState == null || !currentLocalState.terminated)) {
val terminatingChange = groupStateDiff.serverHistory
val terminatorRecipientId: RecipientId? = if (updatedGroupState.terminated && (currentLocalState == null || !currentLocalState.terminated)) {
groupStateDiff.serverHistory
.mapNotNull { it.change }
.firstOrNull { it.terminateGroup }
?.let { ServiceId.parseOrNull(it.editorServiceIdBytes) }
?.let { RecipientId.from(it) }
} else {
null
}
if (terminatingChange != null) {
val editorServiceId = ServiceId.parseOrNull(terminatingChange.editorServiceIdBytes)
if (editorServiceId != null) {
val terminatorRecipientId = RecipientId.from(editorServiceId)
SignalDatabase.groups.setTerminatedBy(groupId, terminatorRecipientId)
}
}
saveGroupState(groupStateDiff, updatedGroupState, groupSendEndorsements, terminatorRecipientId)
if (terminatorRecipientId != null) {
profileAndMessageHelper.stopAllTypingForGroup()
}
if (currentLocalState == null || currentLocalState.revision == RESTORE_PLACEHOLDER_REVISION) {
@@ -561,7 +579,7 @@ class GroupsV2StateProcessor private constructor(
return InternalUpdateResult.Updated(updatedGroupState)
}
private fun saveGroupState(groupStateDiff: GroupStateDiff, updatedGroupState: DecryptedGroup, groupSendEndorsements: ReceivedGroupSendEndorsements?) {
private fun saveGroupState(groupStateDiff: GroupStateDiff, updatedGroupState: DecryptedGroup, groupSendEndorsements: ReceivedGroupSendEndorsements?, terminatorRecipientId: RecipientId? = null) {
val previousGroupState = groupStateDiff.previousGroupState
if (groupSendEndorsements != null) {
@@ -573,12 +591,12 @@ class GroupsV2StateProcessor private constructor(
if (groupId == null) {
Log.w(TAG, "$logPrefix Group create failed, trying to update")
SignalDatabase.groups.update(groupMasterKey, updatedGroupState, groupSendEndorsements)
SignalDatabase.groups.update(groupMasterKey, updatedGroupState, groupSendEndorsements, terminatorRecipientId)
}
updatedGroupState.avatar.isNotEmpty()
} else {
SignalDatabase.groups.update(groupMasterKey, updatedGroupState, groupSendEndorsements)
SignalDatabase.groups.update(groupMasterKey, updatedGroupState, groupSendEndorsements, terminatorRecipientId)
updatedGroupState.avatar != previousGroupState.avatar
}
@@ -735,6 +753,89 @@ class GroupsV2StateProcessor private constructor(
SignalDatabase.groups.remove(groupId, Recipient.self().id)
}
fun markTerminatedLocally() {
val group = SignalDatabase.groups.getGroup(groupId).orNull()
if (group == null) {
Log.w(TAG, "Group not found when inserting terminated message for $groupId")
return
}
if (group.isTerminated) {
Log.w(TAG, "Group $groupId is already marked as terminated.")
return
}
val groupRecipient = Recipient.externalGroupExact(groupId)
val decryptedGroup = group.requireV2GroupProperties().decryptedGroup
val simulatedGroupState = decryptedGroup.copy(terminated = true)
val simulatedGroupChange = DecryptedGroupChange(terminateGroup = true)
val updateDescription = GroupProtoUtil.createOutgoingGroupV2UpdateDescription(masterKey, GroupMutation(decryptedGroup, simulatedGroupChange, simulatedGroupState), null)
val terminateMessage = OutgoingMessage.groupUpdateMessage(groupRecipient, updateDescription, System.currentTimeMillis(), isSelfGroupAdd = false)
try {
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(groupRecipient)
val id = SignalDatabase.messages.insertMessageOutbox(terminateMessage, threadId, false, null).messageId
SignalDatabase.messages.markAsSent(id, true)
SignalDatabase.threads.update(threadId, unarchive = false, allowDeletion = false)
} catch (e: MmsException) {
Log.w(TAG, "Failed to insert terminated group message for $groupId", e)
}
SignalDatabase.groups.update(masterKey, simulatedGroupState, null)
}
fun markJoinRequestRejectedLocally() {
val group = SignalDatabase.groups.getGroup(groupId).orNull()
if (group == null) {
Log.w(TAG, "Group not found when inserting join request rejection message for $groupId")
return
}
val decryptedGroup = group.requireV2GroupProperties().decryptedGroup
if (decryptedGroup.requestingMembers.none { ACI.parseOrNull(it.aciBytes) == aci }) {
Log.w(TAG, "Not a requesting member of $groupId, skipping rejection insert")
return
}
val groupRecipient = Recipient.externalGroupExact(groupId)
val simulatedGroupState = decryptedGroup.copy(
requestingMembers = decryptedGroup.requestingMembers.filter { ACI.parseOrNull(it.aciBytes) != aci }
)
val simulatedGroupChange = DecryptedGroupChange(
editorServiceIdBytes = ACI.UNKNOWN.toByteString(),
deleteRequestingMembers = listOf(aci.toByteString())
)
val updateDescription = GroupProtoUtil.createOutgoingGroupV2UpdateDescription(masterKey, GroupMutation(decryptedGroup, simulatedGroupChange, simulatedGroupState), null)
val rejectedMessage = OutgoingMessage.groupUpdateMessage(groupRecipient, updateDescription, System.currentTimeMillis(), isSelfGroupAdd = false)
try {
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(groupRecipient)
val id = SignalDatabase.messages.insertMessageOutbox(rejectedMessage, threadId, false, null).messageId
SignalDatabase.messages.markAsSent(id, true)
SignalDatabase.threads.update(threadId, unarchive = false, allowDeletion = false)
} catch (e: MmsException) {
Log.w(TAG, "Failed to insert rejected join request message for $groupId", e)
}
SignalDatabase.groups.update(masterKey, simulatedGroupState, null)
}
fun stopAllTypingForGroup() {
if (TextSecurePreferences.isTypingIndicatorsEnabled(AppDependencies.application)) {
val threadId = SignalDatabase.threads.getThreadIdFor(SignalDatabase.recipients.getOrInsertFromGroupId(groupId))
if (threadId != null) {
AppDependencies.typingStatusRepository.stopAllTypingForThread(threadId)
}
}
}
fun persistLearnedProfileKeys(groupStateDiff: GroupStateDiff) {
val profileKeys = ProfileKeySet()

View File

@@ -8,6 +8,8 @@ import androidx.annotation.WorkerThread;
import com.annimon.stream.Stream;
import org.signal.core.util.Util;
import org.signal.core.util.UuidUtil;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.crypto.SealedSenderAccessUtil;
@@ -35,7 +37,6 @@ import org.thoughtcrime.securesms.transport.RetryLaterException;
import org.thoughtcrime.securesms.transport.UndeliverableMessageException;
import org.thoughtcrime.securesms.util.MessageUtil;
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
import org.signal.core.util.Util;
import org.whispersystems.signalservice.api.SignalServiceMessageSender;
import org.whispersystems.signalservice.api.SignalServiceMessageSender.IndividualSendEvents;
import org.whispersystems.signalservice.api.crypto.ContentHint;
@@ -50,7 +51,6 @@ import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException;
import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException;
import org.whispersystems.signalservice.api.push.exceptions.UnregisteredUserException;
import org.signal.core.util.UuidUtil;
import org.whispersystems.signalservice.internal.push.BodyRange;
import org.whispersystems.signalservice.internal.push.DataMessage;

View File

@@ -15,11 +15,14 @@ import androidx.annotation.Nullable;
import com.annimon.stream.Stream;
import org.greenrobot.eventbus.EventBus;
import org.signal.blurhash.BlurHash;
import org.signal.core.models.ServiceId.ACI;
import org.signal.core.util.Base64;
import org.signal.core.util.Hex;
import org.signal.core.util.Util;
import org.signal.core.util.logging.Log;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation;
import org.signal.blurhash.BlurHash;
import org.thoughtcrime.securesms.TextSecureExpiredException;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.attachments.AttachmentId;
@@ -43,7 +46,6 @@ import org.thoughtcrime.securesms.events.PartProgressEvent;
import org.thoughtcrime.securesms.jobmanager.Job;
import org.thoughtcrime.securesms.jobmanager.JobManager;
import org.thoughtcrime.securesms.jobmanager.JobTracker;
import org.thoughtcrime.securesms.jobmanager.impl.BackoffUtil;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.mms.OutgoingMessage;
@@ -57,10 +59,7 @@ 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.signal.core.util.Base64;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.signal.core.util.Util;
import org.whispersystems.signalservice.api.messages.AttachmentTransferProgress;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer;
@@ -68,17 +67,12 @@ import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemo
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
import org.whispersystems.signalservice.api.messages.SignalServicePreview;
import org.whispersystems.signalservice.api.messages.shared.SharedContact;
import org.signal.core.models.ServiceId.ACI;
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException;
import org.whispersystems.signalservice.api.push.exceptions.RateLimitException;
import org.whispersystems.signalservice.api.push.exceptions.RetryNetworkException;
import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException;
import org.whispersystems.signalservice.internal.push.BodyRange;
import java.io.IOException;
import java.io.InputStream;
import java.util.Collection;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;