diff --git a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java index 3cdb39ae2d..0ccbb32cd8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java @@ -94,6 +94,7 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable, void onChangeNumberUpdateContact(@NonNull Recipient recipient); void onCallToAction(@NonNull String action); void onDonateClicked(); + void onBlockJoinRequest(@NonNull Recipient recipient); /** @return true if handled, false if you want to let the normal url handling continue */ boolean onUrlClicked(@NonNull String url); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsRepository.kt index 55cf5c0c18..9af6fcb060 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsRepository.kt @@ -177,6 +177,7 @@ class ConversationSettingsRepository( val groupActionResult = GroupManager.addMembers(context, groupId.requirePush(), selected) GroupAddMembersResult.Success(groupActionResult.addedMemberCount, Recipient.resolvedList(groupActionResult.invitedMembers)) } catch (e: Exception) { + Log.d(TAG, "Failure to add member", e) GroupAddMembersResult.Failure(GroupChangeFailureReason.fromException(e)) } ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java index 1881dcd72d..47e509bc93 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -115,10 +115,12 @@ import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionPlayerHolder; import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionRecycler; import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange; +import org.thoughtcrime.securesms.groups.ui.GroupErrors; import org.thoughtcrime.securesms.groups.ui.invitesandrequests.invite.GroupLinkInviteFriendsBottomSheetDialogFragment; import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupDescriptionDialog; import org.thoughtcrime.securesms.groups.ui.migration.GroupsV1MigrationInfoBottomSheetDialogFragment; import org.thoughtcrime.securesms.groups.v2.GroupDescriptionUtil; +import org.thoughtcrime.securesms.groups.v2.GroupManagementRepository; import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob; import org.thoughtcrime.securesms.jobs.MultiDeviceViewOnceOpenJob; import org.thoughtcrime.securesms.keyvalue.SignalStore; @@ -151,6 +153,7 @@ import org.thoughtcrime.securesms.stickers.StickerPackPreviewActivity; import org.thoughtcrime.securesms.util.CachedInflater; import org.thoughtcrime.securesms.util.CommunicationActions; import org.thoughtcrime.securesms.util.HtmlUtil; +import org.thoughtcrime.securesms.util.LifecycleDisposable; import org.thoughtcrime.securesms.util.MessageRecordUtil; import org.thoughtcrime.securesms.util.RemoteDeleteUtil; import org.thoughtcrime.securesms.util.SaveAttachmentTask; @@ -236,6 +239,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect private Colorizer colorizer; private ConversationUpdateTick conversationUpdateTick; private MultiselectItemDecoration multiselectItemDecoration; + private LifecycleDisposable lifecycleDisposable; public static void prepare(@NonNull Context context) { FrameLayout parent = new FrameLayout(context); @@ -322,6 +326,9 @@ public class ConversationFragment extends LoggingFragment implements Multiselect giphyMp4ProjectionRecycler = initializeGiphyMp4(); + lifecycleDisposable = new LifecycleDisposable(); + lifecycleDisposable.bindTo(getViewLifecycleOwner()); + this.groupViewModel = new ViewModelProvider(getParentFragment(), new ConversationGroupViewModel.Factory()).get(ConversationGroupViewModel.class); this.messageCountsViewModel = new ViewModelProvider(getParentFragment()).get(MessageCountsViewModel.class); this.conversationViewModel = new ViewModelProvider(getParentFragment(), new ConversationViewModel.Factory()).get(ConversationViewModel.class); @@ -1862,6 +1869,15 @@ public class ConversationFragment extends LoggingFragment implements Multiselect startActivity(AppSettingsActivity.subscriptions(requireContext())); } } + + @Override + public void onBlockJoinRequest(@NonNull Recipient recipient) { + new MaterialAlertDialogBuilder(requireContext()).setTitle(R.string.ConversationFragment__block_request) + .setMessage(getString(R.string.ConversationFragment__s_will_not_be_able_to_join_or_request_to_join_this_group_via_the_group_link, recipient.getDisplayName(requireContext()))) + .setNegativeButton(R.string.ConversationFragment__cancel, null) + .setPositiveButton(R.string.ConversationFragment__block_request_button, (d, w) -> handleBlockJoinRequest(recipient)) + .show(); + } } public void refreshList() { @@ -1892,6 +1908,18 @@ public class ConversationFragment extends LoggingFragment implements Multiselect actionMode = ((AppCompatActivity)getActivity()).startSupportActionMode(actionModeCallback); } + private void handleBlockJoinRequest(@NonNull Recipient recipient) { + lifecycleDisposable.add( + groupViewModel.blockJoinRequests(ConversationFragment.this.recipient.get(), recipient) + .subscribe(result -> { + if (result instanceof GroupManagementRepository.GroupManagementResult.Failure) { + int failureReason = GroupErrors.getUserDisplayMessage(((GroupManagementRepository.GroupManagementResult.Failure) result).getReason()); + Toast.makeText(requireContext(), failureReason, Toast.LENGTH_SHORT).show(); + } + }) + ); + } + private final class CheckExpirationDataObserver extends RecyclerView.AdapterDataObserver { @Override public void onItemRangeRemoved(int positionStart, int itemCount) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationGroupViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationGroupViewModel.java index 259dfa4ac9..f83aa25878 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationGroupViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationGroupViewModel.java @@ -26,6 +26,8 @@ import org.thoughtcrime.securesms.groups.GroupManager; import org.thoughtcrime.securesms.groups.GroupsV1MigrationUtil; import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason; import org.thoughtcrime.securesms.groups.ui.invitesandrequests.invite.GroupLinkInviteFriendsBottomSheetDialogFragment; +import org.thoughtcrime.securesms.groups.v2.GroupManagementRepository; +import org.thoughtcrime.securesms.groups.v2.GroupManagementRepository.GroupManagementResult; import org.thoughtcrime.securesms.profiles.spoofing.ReviewRecipient; import org.thoughtcrime.securesms.profiles.spoofing.ReviewUtil; import org.thoughtcrime.securesms.recipients.Recipient; @@ -38,6 +40,9 @@ import java.io.IOException; import java.util.Collections; import java.util.List; +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; +import io.reactivex.rxjava3.core.Single; + final class ConversationGroupViewModel extends ViewModel { private final MutableLiveData liveRecipient; @@ -46,11 +51,13 @@ final class ConversationGroupViewModel extends ViewModel { private final LiveData actionableRequestingMembers; private final LiveData reviewState; private final LiveData> gv1MigrationSuggestions; + private final GroupManagementRepository groupManagementRepository; private boolean firstTimeInviteFriendsTriggered; private ConversationGroupViewModel() { - this.liveRecipient = new MutableLiveData<>(); + this.liveRecipient = new MutableLiveData<>(); + this.groupManagementRepository = new GroupManagementRepository(); LiveData groupRecord = LiveDataUtil.mapAsync(liveRecipient, ConversationGroupViewModel::getGroupRecordForRecipient); LiveData> duplicates = LiveDataUtil.mapAsync(groupRecord, record -> { @@ -213,6 +220,11 @@ final class ConversationGroupViewModel extends ViewModel { GroupLinkInviteFriendsBottomSheetDialogFragment.show(supportFragmentManager, groupId); } + public Single blockJoinRequests(@NonNull Recipient groupRecipient, @NonNull Recipient recipient) { + return groupManagementRepository.blockJoinRequests(groupRecipient.requireGroupId().requireV2(), recipient) + .observeOn(AndroidSchedulers.mainThread()); + } + static final class ReviewState { private static final ReviewState EMPTY = new ReviewState(null, Recipient.UNKNOWN, 0); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java index 22cf8e2e3d..6059bb6d0e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java @@ -33,6 +33,7 @@ import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord; import org.thoughtcrime.securesms.database.model.LiveUpdateMessage; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.UpdateDescription; +import org.thoughtcrime.securesms.groups.LiveGroup; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.recipients.LiveRecipient; @@ -85,6 +86,7 @@ public final class ConversationUpdateItem extends FrameLayout private final PresentOnChange presentOnChange = new PresentOnChange(); private final RecipientObserverManager senderObserver = new RecipientObserverManager(presentOnChange); private final RecipientObserverManager groupObserver = new RecipientObserverManager(presentOnChange); + private final GroupDataManager groupData = new GroupDataManager(presentOnChange); public ConversationUpdateItem(Context context) { super(context); @@ -153,8 +155,9 @@ public final class ConversationUpdateItem extends FrameLayout senderObserver.observe(lifecycleOwner, messageRecord.getIndividualRecipient()); - if (conversationRecipient.isActiveGroup() && conversationMessage.getMessageRecord().isGroupCall()) { + if (conversationRecipient.isActiveGroup() && (conversationMessage.getMessageRecord().isGroupCall() || conversationMessage.getMessageRecord().isCollapsedGroupV2JoinUpdate())) { groupObserver.observe(lifecycleOwner, conversationRecipient); + groupData.observe(lifecycleOwner, conversationRecipient); } else { groupObserver.observe(lifecycleOwner, null); } @@ -269,6 +272,47 @@ public final class ConversationUpdateItem extends FrameLayout } } + static final class GroupDataManager { + + private final Observer recipientObserver; + private final Observer isSelfAdminSetter; + + private LiveGroup liveGroup; + private LiveData liveIsSelfAdmin; + private boolean isSelfAdmin; + private Recipient conversationRecipient; + + GroupDataManager(@NonNull Observer observer) { + this.recipientObserver = observer; + this.isSelfAdminSetter = isSelfAdmin -> { + this.isSelfAdmin = isSelfAdmin; + recipientObserver.onChanged(conversationRecipient); + }; + } + + public void observe(@NonNull LifecycleOwner lifecycleOwner, @Nullable Recipient recipient) { + if (liveGroup != null) { + liveIsSelfAdmin.removeObserver(isSelfAdminSetter); + liveIsSelfAdmin = null; + } + + if (recipient != null) { + conversationRecipient = recipient; + liveGroup = new LiveGroup(recipient.requireGroupId()); + liveIsSelfAdmin = liveGroup.isSelfAdmin(); + + liveIsSelfAdmin.observe(lifecycleOwner, isSelfAdminSetter); + } else { + conversationRecipient = null; + liveGroup = null; + } + } + + public boolean isSelfAdmin() { + return isSelfAdmin; + } + } + @Override public @NonNull MultiselectPart getMultiselectPartForLatestTouch() { return conversationMessage.getMultiselectCollection().asSingle().getSinglePart(); @@ -427,6 +471,14 @@ public final class ConversationUpdateItem extends FrameLayout eventListener.onChangeNumberUpdateContact(conversationMessage.getMessageRecord().getIndividualRecipient()); } }); + } else if (conversationMessage.getMessageRecord().isCollapsedGroupV2JoinUpdate() && groupData.isSelfAdmin()) { + actionButton.setText(R.string.ConversationUpdateItem_block_request); + actionButton.setVisibility(VISIBLE); + actionButton.setOnClickListener(v -> { + if (batchSelected.isEmpty() && eventListener != null) { + eventListener.onBlockJoinRequest(conversationMessage.getMessageRecord().getIndividualRecipient()); + } + }); } else { actionButton.setVisibility(GONE); actionButton.setOnClickListener(null); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java index 4fd026e759..07e1958da2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java @@ -73,8 +73,6 @@ import java.util.Set; import java.util.UUID; import java.util.function.Function; -import kotlin.collections.CollectionsKt; - /** * The base class for message record models that are displayed in * conversations, as opposed to models that are displayed in a thread list. @@ -423,6 +421,15 @@ public abstract class MessageRecord extends DisplayRecord { return false; } + public boolean isCollapsedGroupV2JoinUpdate() { + DecryptedGroupV2Context decryptedGroupV2Context = getDecryptedGroupV2Context(); + if (decryptedGroupV2Context != null && decryptedGroupV2Context.hasChange()) { + DecryptedGroupChange change = decryptedGroupV2Context.getChange(); + return change.getNewRequestingMembersCount() > 0 && change.getDeleteRequestingMembersCount() > 0; + } + return false; + } + public static @NonNull String createNewContextWithAppendedDeleteJoinRequest(@NonNull MessageRecord messageRecord, int revision, @NonNull ByteString id) { DecryptedGroupV2Context decryptedGroupV2Context = messageRecord.getDecryptedGroupV2Context(); 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 27d86fd6ae..538ddffe31 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java @@ -1207,7 +1207,7 @@ final class GroupManagerV2 { DecryptedGroupChange plainGroupChange = groupMutation.getGroupChange(); - if (plainGroupChange != null && DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(plainGroupChange)) { + if (plainGroupChange != null && DecryptedGroupUtil.changeIsSilent(plainGroupChange)) { if (sendToMembers) { ApplicationDependencies.getJobManager().add(PushGroupSilentUpdateSendJob.create(context, groupId, groupMutation.getNewGroupState(), outgoingMessage)); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/GroupManagementRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/GroupManagementRepository.kt new file mode 100644 index 0000000000..5c8074d208 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/GroupManagementRepository.kt @@ -0,0 +1,41 @@ +package org.thoughtcrime.securesms.groups.v2 + +import android.content.Context +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.groups.GroupChangeException +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.groups.GroupManager +import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason +import org.thoughtcrime.securesms.recipients.Recipient +import java.io.IOException + +private val TAG: String = Log.tag(GroupManagementRepository::class.java) + +/** + * Single source repository for managing groups. + */ +class GroupManagementRepository @JvmOverloads constructor(private val context: Context = ApplicationDependencies.getApplication()) { + + fun blockJoinRequests(groupId: GroupId.V2, recipient: Recipient): Single { + return Single.fromCallable { + try { + GroupManager.ban(context, groupId, recipient.id) + GroupManagementResult.Success + } catch (e: GroupChangeException) { + Log.w(TAG, e) + GroupManagementResult.Failure(GroupChangeFailureReason.fromException(e)) + } catch (e: IOException) { + Log.w(TAG, e) + GroupManagementResult.Failure(GroupChangeFailureReason.fromException(e)) + } + }.subscribeOn(Schedulers.io()) + } + + sealed class GroupManagementResult { + object Success : GroupManagementResult() + data class Failure(val reason: GroupChangeFailureReason) : GroupManagementResult() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java index 0dbcf8de1d..7911c364ba 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java @@ -612,6 +612,8 @@ public class GroupsV2StateProcessor { for (LocalGroupLogEntry entry : processedLogEntries) { if (entry.getChange() != null && DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(entry.getChange()) && !DecryptedGroupUtil.changeIsEmpty(entry.getChange())) { Log.d(TAG, "Skipping profile key changes only update message"); + } if (entry.getChange() != null && DecryptedGroupUtil.changeIsEmptyExceptForBanChangesAndOptionalProfileKeyChanges(entry.getChange())) { + Log.d(TAG, "Skipping ban changes only update message"); } else { if (entry.getChange() != null && DecryptedGroupUtil.changeIsEmpty(entry.getChange()) && previousGroupState != null) { Log.w(TAG, "Empty group update message seen. Not inserting."); diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a2d2a44690..07a26d7585 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -367,6 +367,14 @@ Your safety number with %s changed, likely because they reinstalled Signal or changed devices. Tap Verify to confirm the new safety number. This is optional. %1$s on + + Block request? + + %1$s will not be able to join or request to join this group via the group link. They can still be added to the group manually. + + Block request + + Cancel Delete selected conversation? @@ -2047,6 +2055,8 @@ Invite friends Enable Call Notifications Update contact + + Block request No groups in common. Review requests carefully. No contacts in this group. Review requests carefully. View diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil.java index e34ff76441..955fe0fa34 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil.java @@ -627,6 +627,9 @@ public final class DecryptedGroupUtil { changeIsEmptyExceptForProfileKeyChanges(change); } + /* + * When updating this, update {@link #changeIsEmptyExceptForBanChangesAndOptionalProfileKeyChanges(DecryptedGroupChange)} + */ public static boolean changeIsEmptyExceptForProfileKeyChanges(DecryptedGroupChange change) { return change.getNewMembersCount() == 0 && // field 3 change.getDeleteMembersCount() == 0 && // field 4 @@ -650,6 +653,28 @@ public final class DecryptedGroupUtil { change.getDeleteBannedMembersCount() == 0; // field 23 } + public static boolean changeIsEmptyExceptForBanChangesAndOptionalProfileKeyChanges(DecryptedGroupChange change) { + return (change.getNewBannedMembersCount() != 0 || change.getDeleteBannedMembersCount() != 0) && + change.getNewMembersCount() == 0 && // field 3 + change.getDeleteMembersCount() == 0 && // field 4 + change.getModifyMemberRolesCount() == 0 && // field 5 + change.getNewPendingMembersCount() == 0 && // field 7 + change.getDeletePendingMembersCount() == 0 && // field 8 + change.getPromotePendingMembersCount() == 0 && // field 9 + !change.hasNewTitle() && // field 10 + !change.hasNewAvatar() && // field 11 + !change.hasNewTimer() && // field 12 + isEmpty(change.getNewAttributeAccess()) && // field 13 + isEmpty(change.getNewMemberAccess()) && // field 14 + isEmpty(change.getNewInviteLinkAccess()) && // field 15 + change.getNewRequestingMembersCount() == 0 && // field 16 + change.getDeleteRequestingMembersCount() == 0 && // field 17 + change.getPromoteRequestingMembersCount() == 0 && // field 18 + change.getNewInviteLinkPassword().size() == 0 && // field 19 + !change.hasNewDescription() && // field 20 + isEmpty(change.getNewIsAnnouncementGroup()); // field 21 + } + static boolean isEmpty(AccessControl.AccessRequired newAttributeAccess) { return newAttributeAccess == AccessControl.AccessRequired.UNKNOWN; } @@ -657,4 +682,8 @@ public final class DecryptedGroupUtil { static boolean isEmpty(EnabledState enabledState) { return enabledState == EnabledState.UNKNOWN; } + + public static boolean changeIsSilent(DecryptedGroupChange plainGroupChange) { + return changeIsEmptyExceptForProfileKeyChanges(plainGroupChange) || changeIsEmptyExceptForBanChangesAndOptionalProfileKeyChanges(plainGroupChange); + } }