diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/TypingStatusRepository.java b/app/src/main/java/org/thoughtcrime/securesms/components/TypingStatusRepository.java index ba8014484c..978dce0240 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/TypingStatusRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/TypingStatusRepository.java @@ -101,6 +101,19 @@ public class TypingStatusRepository { return threadsNotifier; } + public synchronized void stopAllTypingForThread(long threadId) { + Set 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 notifier : notifiers.values()) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index b090aacf0b..6ecb600428 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -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") diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index dc1192f7e0..8c82440628 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -406,7 +406,7 @@ class ConversationViewModel( val pendingGroupJoinFlow: Flow = groupRecordFlow .map { PendingGroupJoinRequestsBanner( - suggestionsSize = it.actionableRequestingMembersCount, + suggestionsSize = if (it.isTerminated) 0 else it.actionableRequestingMembersCount, onViewClicked = groupJoinClickListener ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/TerminatedGroupBottomSheetDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/TerminatedGroupBottomSheetDialog.kt index b57d1d7183..5955a2a7f9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/TerminatedGroupBottomSheetDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/TerminatedGroupBottomSheetDialog.kt @@ -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)) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.kt index 32ebd76aa2..c40c7afaff 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.kt @@ -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 = 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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java index 60c19d738f..4b2d5fe1d4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupChangeFailureReason.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupChangeFailureReason.java index d0b32e3215..4322358b12 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupChangeFailureReason.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupChangeFailureReason.java @@ -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; diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupErrors.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupErrors.java index 359bcd9a9b..524819bb4b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupErrors.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupErrors.java @@ -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; } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/FetchGroupDetailsError.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/FetchGroupDetailsError.java index 180b489946..6e7c4c2f55 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/FetchGroupDetailsError.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/FetchGroupDetailsError.java @@ -3,5 +3,6 @@ package org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining; enum FetchGroupDetailsError { GroupLinkNotActive, BannedFromGroup, - NetworkError + NetworkError, + GroupTerminated } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinBottomSheetDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinBottomSheetDialogFragment.java index 578ec56da4..1691628e06 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinBottomSheetDialogFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinBottomSheetDialogFragment.java @@ -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); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinRepository.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinRepository.java index a49f532d53..27ed9eda62 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinRepository.java @@ -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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/JoinGroupError.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/JoinGroupError.java index 7047bae047..bf83d5583e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/JoinGroupError.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/JoinGroupError.java @@ -7,4 +7,5 @@ enum JoinGroupError { FAILED, LIMIT_REACHED, NETWORK_ERROR, + GROUP_TERMINATED, } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.kt index 8b5c2916e2..5c89656543 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.kt @@ -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() diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/IndividualSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/IndividualSendJob.java index e27ab85e5c..a9936195d8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/IndividualSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/IndividualSendJob.java @@ -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; diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java index 6bc78214ee..ed113ac267 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java @@ -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; diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cb136efb83..8b97455cf8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1680,6 +1680,8 @@ Unable to join group. Please try again later Encountered a network error. This group link is not active + + The group has been ended, unable to join group. Group limit reached, unable to join group @@ -3637,6 +3639,8 @@ Message could not be sent. Check your connection and try again. Send failed because the group was ended. You can no longer send and receive messages in this group. + + You can no longer invite friends or add members to this group. Message failed to delete. Check your connection and try again. @@ -8578,8 +8582,6 @@ The group has been ended You can no longer send and receive messages or calls in this group. - - Okay Deleting is now synced across all of your devices diff --git a/app/src/test/java/org/thoughtcrime/securesms/groups/GroupManagerV2Test_edit.kt b/app/src/test/java/org/thoughtcrime/securesms/groups/GroupManagerV2Test_edit.kt index 05da91a3e5..d54e34c80c 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/groups/GroupManagerV2Test_edit.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/groups/GroupManagerV2Test_edit.kt @@ -129,7 +129,8 @@ class GroupManagerV2Test_edit { every { groupTable.getGroup(groupId) } returns data.groupRecord every { groupTable.requireGroup(groupId) } returns data.groupRecord.get() - every { groupTable.update(any(), any(), any()) } returns Unit + every { groupTable.update(any(), any(), any(), anyNullable()) } returns Unit + every { Recipient.self() } returns RecipientDatabaseTestUtils.createRecipient(isSelf = true, recipientId = RecipientId.from(1L)) every { sendGroupUpdateHelper.sendGroupUpdate(masterKey, any(), any(), any()) } returns GroupManagerV2.RecipientAndThread(Recipient.UNKNOWN, 1) every { groupsV2API.patchGroup(any(), any(), any()) } returns GroupChangeResponse(group_change = data.groupChange!!) every { Recipient.externalGroupExact(groupId) } returns RecipientDatabaseTestUtils.createRecipient(resolved = true) @@ -141,7 +142,7 @@ class GroupManagerV2Test_edit { private fun then(then: (DecryptedGroup) -> Unit) { val decryptedGroupArg = slot() - verify { groupTable.update(groupId, capture(decryptedGroupArg), any()) } + verify { groupTable.update(groupId, capture(decryptedGroupArg), any(), anyNullable()) } then(decryptedGroupArg.captured) } diff --git a/app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessorTest.kt b/app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessorTest.kt index 0494cdd223..4d33964cf5 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessorTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessorTest.kt @@ -161,7 +161,7 @@ class GroupsV2StateProcessorTest { every { groupsV2Authorization.getAuthorizationForToday(serviceIds, secretParams) } returns null if (data.expectTableUpdate) { - justRun { groupTable.update(any(), any(), any()) } + justRun { groupTable.update(any(), any(), anyNullable(), anyNullable()) } } if (data.expectTableCreate) { @@ -171,6 +171,7 @@ class GroupsV2StateProcessorTest { if (data.expectTableUpdate || data.expectTableCreate) { justRun { profileAndMessageHelper.storeMessage(any(), any(), any()) } justRun { profileAndMessageHelper.persistLearnedProfileKeys(any()) } + justRun { profileAndMessageHelper.stopAllTypingForGroup() } } data.serverState?.let { serverState -> @@ -1052,7 +1053,7 @@ class GroupsV2StateProcessorTest { } @Test - fun `when P2P change terminates group with known editor, then setTerminatedBy is called`() { + fun `when P2P change terminates group with known editor, then update is called with terminator recipient id`() { val adminAci: ACI = ACI.from(UUID.randomUUID()) val adminRecipientId = RecipientId.from(200) @@ -1065,7 +1066,6 @@ class GroupsV2StateProcessorTest { } every { recipientTable.getAndPossiblyMerge(adminAci, null) } returns adminRecipientId - justRun { groupTable.setTerminatedBy(groupId, adminRecipientId) } val signedChange = DecryptedGroupChange( revision = 6, @@ -1087,7 +1087,7 @@ class GroupsV2StateProcessorTest { assertThat(it.terminated, "group should be terminated").isEqualTo(true) } - verify { groupTable.setTerminatedBy(groupId, adminRecipientId) } + verify { groupTable.update(masterKey, match { it.terminated }, null, adminRecipientId) } } @Test diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java index 58abded13c..ae321fe1ef 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java @@ -81,6 +81,7 @@ import org.whispersystems.signalservice.internal.push.exceptions.ForbiddenExcept import org.whispersystems.signalservice.internal.push.exceptions.GroupExistsException; import org.whispersystems.signalservice.internal.push.exceptions.GroupNotFoundException; import org.whispersystems.signalservice.internal.push.exceptions.GroupPatchNotAcceptedException; +import org.whispersystems.signalservice.internal.push.exceptions.GroupTerminatedException; import org.whispersystems.signalservice.internal.push.exceptions.MismatchedDevicesException; import org.whispersystems.signalservice.internal.push.exceptions.NotInGroupException; import org.whispersystems.signalservice.internal.push.exceptions.StaleDevicesException; @@ -1887,6 +1888,10 @@ public class PushServiceSocket { throw message != null ? new GroupPatchNotAcceptedException(message) : new GroupPatchNotAcceptedException(); } + + if (responseCode == 423) { + throw new GroupTerminatedException(); + } }; private static final ResponseCodeHandler GROUPS_V2_GET_JOIN_INFO_HANDLER = (responseCode, body, getHeader) -> { @@ -1897,6 +1902,10 @@ public class PushServiceSocket { if (responseCode == 403) { throw new ForbiddenException(Optional.ofNullable(getHeader.apply("X-Signal-Forbidden-Reason"))); } + + if (responseCode == 423) { + throw new GroupTerminatedException(); + } }; public GroupResponse putNewGroupsV2Group(Group group, GroupsV2AuthorizationString authorization) diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/exceptions/GroupTerminatedException.java b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/exceptions/GroupTerminatedException.java new file mode 100644 index 0000000000..0a1c9fc94e --- /dev/null +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/exceptions/GroupTerminatedException.java @@ -0,0 +1,9 @@ +package org.whispersystems.signalservice.internal.push.exceptions; + +import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException; + +public final class GroupTerminatedException extends NonSuccessfulResponseCodeException { + public GroupTerminatedException() { + super(423); + } +}