diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemShapeTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemShapeTest.kt index 2af26197fd..9a21acf4f9 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemShapeTest.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemShapeTest.kt @@ -322,5 +322,11 @@ class V2ConversationItemShapeTest { override fun onItemClick(item: MultiselectPart?) = Unit override fun onItemLongClick(itemView: View?, item: MultiselectPart?) = Unit + + override fun onShowSafetyTips(forGroup: Boolean) = Unit + + override fun onReportSpamLearnMoreClicked() = Unit + + override fun onMessageRequestAcceptOptionsClicked() = Unit } } diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/database/SmsDatabaseTest_collapseJoinRequestEventsIfPossible.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/database/SmsDatabaseTest_collapseJoinRequestEventsIfPossible.kt index 6ea77484b5..b00aba7111 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/database/SmsDatabaseTest_collapseJoinRequestEventsIfPossible.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/database/SmsDatabaseTest_collapseJoinRequestEventsIfPossible.kt @@ -290,7 +290,8 @@ class SmsDatabaseTest_collapseJoinRequestEventsIfPossible { from = sender, timestamp = wallClock, groupId = groupId, - groupContext = groupContext + groupContext = groupContext, + serverGuid = null ) } diff --git a/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/InternalConversationTestFragment.kt b/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/InternalConversationTestFragment.kt index 03cdaaf39d..5747694341 100644 --- a/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/InternalConversationTestFragment.kt +++ b/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/InternalConversationTestFragment.kt @@ -298,5 +298,17 @@ class InternalConversationTestFragment : Fragment(R.layout.conversation_test_fra override fun onItemLongClick(itemView: View?, item: MultiselectPart?) { Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show() } + + override fun onShowSafetyTips(forGroup: Boolean) { + Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show() + } + + override fun onReportSpamLearnMoreClicked() { + Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show() + } + + override fun onMessageRequestAcceptOptionsClicked() { + Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show() + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java index 05263f3352..8b5ff1aaca 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java @@ -123,5 +123,8 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable, void onEditedIndicatorClicked(@NonNull MessageRecord messageRecord); void onShowGroupDescriptionClicked(@NonNull String groupName, @NonNull String description, boolean shouldLinkifyWebLinks); void onJoinCallLink(@NonNull CallLinkRootKey callLinkRootKey); + void onShowSafetyTips(boolean forGroup); + void onReportSpamLearnMoreClicked(); + void onMessageRequestAcceptOptionsClicked(); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/BlockUnblockDialog.java b/app/src/main/java/org/thoughtcrime/securesms/BlockUnblockDialog.java index 5f186ece37..c35064d60f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/BlockUnblockDialog.java +++ b/app/src/main/java/org/thoughtcrime/securesms/BlockUnblockDialog.java @@ -11,16 +11,36 @@ import androidx.lifecycle.Lifecycle; import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import org.thoughtcrime.securesms.database.SignalDatabase; -import org.thoughtcrime.securesms.recipients.Recipient; import org.signal.core.util.concurrent.SimpleTask; +import org.signal.storageservice.protos.groups.local.DecryptedPendingMember; +import org.thoughtcrime.securesms.database.SignalDatabase; +import org.thoughtcrime.securesms.database.model.GroupRecord; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil; +import org.whispersystems.signalservice.api.push.ServiceId; + +import java.util.List; +import java.util.Optional; + +import okio.ByteString; /** * This should be used whenever we want to prompt the user to block/unblock a recipient. */ public final class BlockUnblockDialog { - private BlockUnblockDialog() { } + private BlockUnblockDialog() {} + + public static void showReportSpamFor(@NonNull Context context, + @NonNull Lifecycle lifecycle, + @NonNull Recipient recipient, + @NonNull Runnable onReportSpam, + @Nullable Runnable onBlockAndReportSpam) + { + SimpleTask.run(lifecycle, + () -> buildReportSpamFor(context, recipient, onReportSpam, onBlockAndReportSpam), + AlertDialog.Builder::show); + } public static void showBlockFor(@NonNull Context context, @NonNull Lifecycle lifecycle, @@ -137,4 +157,37 @@ public final class BlockUnblockDialog { return builder; } + + @WorkerThread + private static AlertDialog.Builder buildReportSpamFor(@NonNull Context context, + @NonNull Recipient recipient, + @NonNull Runnable onReportSpam, + @Nullable Runnable onBlockAndReportSpam) + { + recipient = recipient.resolve(); + + AlertDialog.Builder builder = new MaterialAlertDialogBuilder(context) + .setTitle(R.string.BlockUnblockDialog_report_spam_title) + .setPositiveButton(R.string.BlockUnblockDialog_report_spam, (d, w) -> onReportSpam.run()); + + if (onBlockAndReportSpam != null) { + builder.setNeutralButton(android.R.string.cancel, null) + .setNegativeButton(R.string.BlockUnblockDialog_report_spam_and_block, (d, w) -> onBlockAndReportSpam.run()); + } else { + builder.setNegativeButton(android.R.string.cancel, null); + } + + if (recipient.isGroup()) { + Recipient adder = SignalDatabase.groups().getGroupInviter(recipient.requireGroupId()); + if (adder != null) { + builder.setMessage(context.getString(R.string.BlockUnblockDialog_report_spam_group_named_adder, adder.getDisplayName(context))); + } else { + builder.setMessage(R.string.BlockUnblockDialog_report_spam_group_unknown_adder); + } + } else { + builder.setMessage(R.string.BlockUnblockDialog_report_spam_description); + } + + return builder; + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt index 6057141155..690afe430c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt @@ -28,7 +28,9 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import io.reactivex.rxjava3.kotlin.subscribeBy import org.signal.core.util.DimensionUnit +import org.signal.core.util.Result import org.signal.core.util.concurrent.LifecycleDisposable +import org.signal.core.util.concurrent.addTo import org.signal.core.util.getParcelableArrayListExtraCompat import org.thoughtcrime.securesms.AvatarPreviewActivity import org.thoughtcrime.securesms.BlockUnblockDialog @@ -74,6 +76,7 @@ import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupInviteSentD import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupsLearnMoreBottomSheetDialogFragment import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory +import org.thoughtcrime.securesms.messagerequests.MessageRequestRepository import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientExporter @@ -112,17 +115,17 @@ class ConversationSettingsFragment : DSLSettingsFragment( private val alertTint by lazy { ContextCompat.getColor(requireContext(), R.color.signal_alert_primary) } private val alertDisabledTint by lazy { ContextCompat.getColor(requireContext(), R.color.signal_alert_primary_50) } private val blockIcon by lazy { - ContextUtil.requireDrawable(requireContext(), R.drawable.ic_block_tinted_24).apply { + ContextUtil.requireDrawable(requireContext(), R.drawable.symbol_block_24).apply { colorFilter = PorterDuffColorFilter(alertTint, PorterDuff.Mode.SRC_IN) } } private val unblockIcon by lazy { - ContextUtil.requireDrawable(requireContext(), R.drawable.ic_block_tinted_24) + ContextUtil.requireDrawable(requireContext(), R.drawable.symbol_block_24) } private val leaveIcon by lazy { - ContextUtil.requireDrawable(requireContext(), R.drawable.ic_leave_tinted_24).apply { + ContextUtil.requireDrawable(requireContext(), R.drawable.symbol_leave_24).apply { colorFilter = PorterDuffColorFilter(alertTint, PorterDuff.Mode.SRC_IN) } } @@ -135,7 +138,8 @@ class ConversationSettingsFragment : DSLSettingsFragment( recipientId = args.recipientId, groupId = ParcelableGroupId.get(groupId), callMessageIds = args.callMessageIds ?: longArrayOf(), - repository = ConversationSettingsRepository(requireContext()) + repository = ConversationSettingsRepository(requireContext()), + messageRequestRepository = MessageRequestRepository(requireContext()) ) } ) @@ -798,6 +802,49 @@ class ConversationSettingsFragment : DSLSettingsFragment( } } ) + + val reportSpamTint = if (state.isDeprecatedOrUnregistered) R.color.signal_alert_primary_50 else R.color.signal_alert_primary + clickPref( + title = DSLSettingsText.from(R.string.ConversationFragment_report_spam, ContextCompat.getColor(requireContext(), reportSpamTint)), + icon = DSLSettingsIcon.from(R.drawable.symbol_spam_24, reportSpamTint), + isEnabled = !state.isDeprecatedOrUnregistered, + onClick = { + BlockUnblockDialog.showReportSpamFor( + requireContext(), + viewLifecycleOwner.lifecycle, + state.recipient, + { + viewModel + .onReportSpam() + .subscribeBy { + Toast.makeText(requireContext(), R.string.ConversationFragment_reported_as_spam, Toast.LENGTH_SHORT).show() + onToolbarNavigationClicked() + } + .addTo(lifecycleDisposable) + }, + if (state.recipient.isBlocked) { + null + } else { + Runnable { + viewModel + .onBlockAndReportSpam() + .subscribeBy { result -> + when (result) { + is Result.Success -> { + Toast.makeText(requireContext(), R.string.ConversationFragment_reported_as_spam_and_blocked, Toast.LENGTH_SHORT).show() + onToolbarNavigationClicked() + } + is Result.Failure -> { + Toast.makeText(requireContext(), GroupErrors.getUserDisplayMessage(result.failure), Toast.LENGTH_SHORT).show() + } + } + } + .addTo(lifecycleDisposable) + } + } + ) + } + ) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsViewModel.kt index 7c3682755e..092c5d436f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsViewModel.kt @@ -1,6 +1,5 @@ package org.thoughtcrime.securesms.components.settings.conversation -import android.database.Cursor import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel @@ -8,11 +7,13 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.distinctUntilChanged import androidx.lifecycle.map import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Maybe import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.kotlin.plusAssign import io.reactivex.rxjava3.subjects.PublishSubject import io.reactivex.rxjava3.subjects.Subject +import org.signal.core.util.Result import org.signal.core.util.ThreadUtil import org.signal.core.util.concurrent.SignalExecutors import org.signal.core.util.readToList @@ -25,8 +26,10 @@ import org.thoughtcrime.securesms.database.model.StoryViewState import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.groups.GroupId import org.thoughtcrime.securesms.groups.LiveGroup +import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason import org.thoughtcrime.securesms.groups.v2.GroupAddMembersResult import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.messagerequests.MessageRequestRepository import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.recipients.RecipientUtil @@ -37,6 +40,7 @@ import org.thoughtcrime.securesms.util.livedata.Store sealed class ConversationSettingsViewModel( private val callMessageIds: LongArray, private val repository: ConversationSettingsRepository, + private val messageRequestRepository: MessageRequestRepository, specificSettingsState: SpecificSettingsState ) : ViewModel() { @@ -90,6 +94,27 @@ sealed class ConversationSettingsViewModel( sharedMediaUpdateTrigger.postValue(Unit) } + fun onReportSpam(): Maybe { + return if (store.state.threadId > 0 && store.state.recipient != Recipient.UNKNOWN) { + messageRequestRepository.reportSpamMessageRequest(store.state.recipient.id, store.state.threadId) + .observeOn(AndroidSchedulers.mainThread()) + .toSingle { Unit } + .toMaybe() + } else { + Maybe.empty() + } + } + + fun onBlockAndReportSpam(): Maybe> { + return if (store.state.threadId > 0 && store.state.recipient != Recipient.UNKNOWN) { + messageRequestRepository.blockAndReportSpamMessageRequest(store.state.recipient.id, store.state.threadId) + .observeOn(AndroidSchedulers.mainThread()) + .toMaybe() + } else { + Maybe.empty() + } + } + open fun refreshRecipient(): Unit = error("This ViewModel does not support this interaction") abstract fun setMuteUntil(muteUntil: Long) @@ -112,19 +137,15 @@ sealed class ConversationSettingsViewModel( disposable.clear() } - private fun Cursor?.ensureClosed() { - if (this != null && !this.isClosed) { - this.close() - } - } - private class RecipientSettingsViewModel( private val recipientId: RecipientId, private val callMessageIds: LongArray, - private val repository: ConversationSettingsRepository + private val repository: ConversationSettingsRepository, + messageRequestRepository: MessageRequestRepository ) : ConversationSettingsViewModel( callMessageIds, repository, + messageRequestRepository, SpecificSettingsState.RecipientSettingsState() ) { @@ -252,8 +273,9 @@ sealed class ConversationSettingsViewModel( private class GroupSettingsViewModel( private val groupId: GroupId, private val callMessageIds: LongArray, - private val repository: ConversationSettingsRepository - ) : ConversationSettingsViewModel(callMessageIds, repository, SpecificSettingsState.GroupSettingsState(groupId)) { + private val repository: ConversationSettingsRepository, + messageRequestRepository: MessageRequestRepository + ) : ConversationSettingsViewModel(callMessageIds, repository, messageRequestRepository, SpecificSettingsState.GroupSettingsState(groupId)) { private val liveGroup = LiveGroup(groupId) @@ -465,15 +487,16 @@ sealed class ConversationSettingsViewModel( private val recipientId: RecipientId? = null, private val groupId: GroupId? = null, private val callMessageIds: LongArray, - private val repository: ConversationSettingsRepository + private val repository: ConversationSettingsRepository, + private val messageRequestRepository: MessageRequestRepository ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { return requireNotNull( modelClass.cast( when { - recipientId != null -> RecipientSettingsViewModel(recipientId, callMessageIds, repository) - groupId != null -> GroupSettingsViewModel(groupId, callMessageIds, repository) + recipientId != null -> RecipientSettingsViewModel(recipientId, callMessageIds, repository, messageRequestRepository) + groupId != null -> GroupSettingsViewModel(groupId, callMessageIds, repository, messageRequestRepository) else -> error("One of RecipientId or GroupId required.") } ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationHeaderView.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationHeaderView.java index f045b7e7bd..c48a360224 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationHeaderView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationHeaderView.java @@ -120,6 +120,12 @@ public class ConversationHeaderView extends ConstraintLayout { return binding.messageRequestDescription; } + public void setButton(@NonNull CharSequence button, Runnable onClick) { + binding.messageRequestButton.setText(button); + binding.messageRequestButton.setOnClickListener(v -> onClick.run()); + binding.messageRequestButton.setVisibility(View.VISIBLE); + } + public void showBackgroundBubble(boolean enabled) { if (enabled) { setBackgroundResource(R.drawable.wallpaper_bubble_background_18); @@ -146,6 +152,10 @@ public class ConversationHeaderView extends ConstraintLayout { binding.messageRequestDescription.setVisibility(View.GONE); } + public void hideButton() { + binding.messageRequestButton.setVisibility(View.GONE); + } + public void setLinkifyDescription(boolean enable) { binding.messageRequestDescription.setMovementMethod(enable ? LongClickMovementMethod.getInstance(getContext()) : null); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationOptionsMenu.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationOptionsMenu.kt index 275969b0be..777874c7a3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationOptionsMenu.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationOptionsMenu.kt @@ -16,6 +16,7 @@ import io.reactivex.rxjava3.kotlin.subscribeBy import org.signal.core.util.concurrent.LifecycleDisposable import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.messagerequests.MessageRequestState import org.thoughtcrime.securesms.recipients.Recipient /** @@ -53,7 +54,7 @@ internal object ConversationOptionsMenu { hasActiveGroupCall, distributionType, threadId, - isInMessageRequest, + messageRequestState, isInBubble ) = callback.getSnapshot() @@ -62,6 +63,23 @@ internal object ConversationOptionsMenu { return } + if (!messageRequestState.isAccepted) { + menuInflater.inflate(R.menu.conversation_message_request, menu) + + if (messageRequestState.isBlocked) { + hideMenuItem(menu, R.id.menu_block) + hideMenuItem(menu, R.id.menu_accept) + } else { + hideMenuItem(menu, R.id.menu_unblock) + } + + if (messageRequestState.reportedAsSpam) { + hideMenuItem(menu, R.id.menu_report_spam) + } + + return + } + if (!afterFirstRenderMode) { createdPreRenderMenu = true if (recipient.isSelf) { @@ -83,12 +101,6 @@ internal object ConversationOptionsMenu { return } - if (isInMessageRequest && !recipient.isBlocked) { - if (isActiveGroup) { - menuInflater.inflate(R.menu.conversation_message_requests_group, menu) - } - } - if (isPushAvailable) { if (recipient.expiresInSeconds > 0) { if (!isInActiveGroup) { @@ -120,10 +132,6 @@ internal object ConversationOptionsMenu { menuInflater.inflate(R.menu.conversation, menu) - if (isInMessageRequest && !recipient.isBlocked) { - hideMenuItem(menu, R.id.menu_conversation_settings) - } - if (!recipient.isGroup && !isPushAvailable && !recipient.isReleaseNotes) { menuInflater.inflate(R.menu.conversation_insecure, menu) } @@ -208,6 +216,11 @@ internal object ConversationOptionsMenu { R.id.menu_expiring_messages_off, R.id.menu_expiring_messages -> callback.handleSelectMessageExpiration() R.id.menu_create_bubble -> callback.handleCreateBubble() R.id.home -> callback.handleGoHome() + R.id.menu_block -> callback.handleBlock() + R.id.menu_unblock -> callback.handleUnblock() + R.id.menu_report_spam -> callback.handleReportSpam() + R.id.menu_accept -> callback.handleMessageRequestAccept() + R.id.menu_delete_chat -> callback.handleDeleteConversation() R.id.edittext_bold, R.id.edittext_italic, R.id.edittext_strikethrough, @@ -244,7 +257,7 @@ internal object ConversationOptionsMenu { val hasActiveGroupCall: Boolean, val distributionType: Int, val threadId: Long, - val isInMessageRequest: Boolean, + val messageRequestState: MessageRequestState, val isInBubble: Boolean ) @@ -276,5 +289,10 @@ internal object ConversationOptionsMenu { fun showExpiring(recipient: Recipient) fun clearExpiring() fun handleFormatText(@IdRes id: Int) + fun handleBlock() + fun handleUnblock() + fun handleReportSpam() + fun handleMessageRequestAccept() + fun handleDeleteConversation() } } 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 ca9fbc1741..64fb93bc71 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java @@ -566,6 +566,22 @@ public final class ConversationUpdateItem extends FrameLayout eventListener.onSendPaymentClicked(conversationMessage.getMessageRecord().getFromRecipient().getId()); } }); + } else if (conversationMessage.getMessageRecord().isReportedSpam()) { + actionButton.setText(R.string.ConversationUpdateItem_learn_more); + actionButton.setVisibility(VISIBLE); + actionButton.setOnClickListener(v -> { + if (batchSelected.isEmpty() && eventListener != null) { + eventListener.onReportSpamLearnMoreClicked(); + } + }); + } else if (conversationMessage.getMessageRecord().isMessageRequestAccepted()) { + actionButton.setText(R.string.ConversationUpdateItem_options); + actionButton.setVisibility(VISIBLE); + actionButton.setOnClickListener(v -> { + if (batchSelected.isEmpty() && eventListener != null) { + eventListener.onMessageRequestAcceptOptionsClicked(); + } + }); } else{ actionButton.setVisibility(GONE); actionButton.setOnClickListener(null); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ScheduledMessagesBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ScheduledMessagesBottomSheet.kt index 10fafdaddc..a15f9a6fca 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ScheduledMessagesBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ScheduledMessagesBottomSheet.kt @@ -274,6 +274,9 @@ class ScheduledMessagesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment override fun onActivatePaymentsClicked() = Unit override fun onSendPaymentClicked(recipientId: RecipientId) = Unit override fun onEditedIndicatorClicked(messageRecord: MessageRecord) = Unit + override fun onShowSafetyTips(forGroup: Boolean) = Unit + override fun onReportSpamLearnMoreClicked() = Unit + override fun onMessageRequestAcceptOptionsClicked() = Unit } companion object { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/quotes/MessageQuotesBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/quotes/MessageQuotesBottomSheet.kt index 4fffcbbebf..bb395c0837 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/quotes/MessageQuotesBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/quotes/MessageQuotesBottomSheet.kt @@ -257,6 +257,10 @@ class MessageQuotesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment() { dismiss() getAdapterListener().onEditedIndicatorClicked(messageRecord) } + + override fun onShowSafetyTips(forGroup: Boolean) = Unit + override fun onReportSpamLearnMoreClicked() = Unit + override fun onMessageRequestAcceptOptionsClicked() = Unit } companion object { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/edit/EditMessageHistoryDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/edit/EditMessageHistoryDialog.kt index b5eb033007..596807e29d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/edit/EditMessageHistoryDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/edit/EditMessageHistoryDialog.kt @@ -165,6 +165,9 @@ class EditMessageHistoryDialog : FixedRoundedCornerBottomSheetDialogFragment() { override fun onActivatePaymentsClicked() = Unit override fun onSendPaymentClicked(recipientId: RecipientId) = Unit override fun onEditedIndicatorClicked(messageRecord: MessageRecord) = Unit + override fun onShowSafetyTips(forGroup: Boolean) = Unit + override fun onReportSpamLearnMoreClicked() = Unit + override fun onMessageRequestAcceptOptionsClicked() = Unit } companion object { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapterV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapterV2.kt index 36a19a5909..78ae08dab2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapterV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapterV2.kt @@ -50,7 +50,6 @@ import org.thoughtcrime.securesms.databinding.V2ConversationItemTextOnlyOutgoing import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicyEnforcer import org.thoughtcrime.securesms.groups.v2.GroupDescriptionUtil import org.thoughtcrime.securesms.keyvalue.SignalStore -import org.thoughtcrime.securesms.messagerequests.MessageRequestState import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.util.CachedInflater @@ -594,13 +593,25 @@ class ConversationAdapterV2( } } - if (sharedGroups.isEmpty() || isSelf) { + conversationBanner.hideButton() + + if (messageRequestState?.isAccepted == false && sharedGroups.isEmpty() && !isSelf && !recipient.isGroup) { + conversationBanner.setDescription(context.getString(R.string.ConversationUpdateItem_no_groups_in_common_review_requests_carefully), R.drawable.symbol_error_circle_24) + conversationBanner.setButton(context.getString(R.string.ConversationFragment_safety_tips)) { + clickListener.onShowSafetyTips(false) + } + } else if (messageRequestState?.isAccepted == false && recipient.isGroup && !groupInfo.hasExistingContacts) { + conversationBanner.setDescription(context.getString(R.string.ConversationUpdateItem_no_contacts_in_this_group_review_requests_carefully), R.drawable.symbol_error_circle_24) + conversationBanner.setButton(context.getString(R.string.ConversationFragment_safety_tips)) { + clickListener.onShowSafetyTips(true) + } + } else if (sharedGroups.isEmpty() || isSelf) { if (TextUtils.isEmpty(groupInfo.description)) { conversationBanner.setLinkifyDescription(false) conversationBanner.hideDescription() } else { conversationBanner.setLinkifyDescription(true) - val linkifyWebLinks = messageRequestState == MessageRequestState.NONE + val linkifyWebLinks = messageRequestState?.isAccepted == true conversationBanner.showDescription() GroupDescriptionUtil.setText( diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationDialogs.kt index 1266c66594..3c9b3deb9b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationDialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationDialogs.kt @@ -10,12 +10,10 @@ import org.signal.core.util.concurrent.SignalExecutors import org.signal.core.util.concurrent.SimpleTask import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity -import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord.NoGroupsInCommon import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.sms.MessageSender -import org.thoughtcrime.securesms.util.CommunicationActions import org.thoughtcrime.securesms.verify.VerifyIdentityActivity /** @@ -82,24 +80,7 @@ object ConversationDialogs { dialog.show() } - fun displayInMemoryMessageDialog(context: Context, messageRecord: MessageRecord) { - if (messageRecord is NoGroupsInCommon) { - val isGroup = messageRecord.isGroup - MaterialAlertDialogBuilder(context, R.style.ThemeOverlay_Signal_MaterialAlertDialog) - .setMessage( - if (isGroup) { - R.string.GroupsInCommonMessageRequest__none_of_your_contacts_or_people_you_chat_with_are_in_this_group - } else { - R.string.GroupsInCommonMessageRequest__you_have_no_groups_in_common_with_this_person - } - ) - .setNeutralButton(R.string.GroupsInCommonMessageRequest__about_message_requests) { _, _ -> - CommunicationActions.openBrowserLink(context, context.getString(R.string.GroupsInCommonMessageRequest__support_article)) - } - .setPositiveButton(R.string.GroupsInCommonMessageRequest__okay, null) - .show() - } - } + fun displayInMemoryMessageDialog(context: Context, messageRecord: MessageRecord) = Unit fun displayMessageCouldNotBeSentDialog(context: Context, messageRecord: MessageRecord) { MaterialAlertDialogBuilder(context) 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 2d79c5b4ee..3a3dc03086 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 @@ -243,7 +243,6 @@ import org.thoughtcrime.securesms.mediasend.Media import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult import org.thoughtcrime.securesms.messagedetails.MessageDetailsFragment import org.thoughtcrime.securesms.messagerequests.MessageRequestRepository -import org.thoughtcrime.securesms.messagerequests.MessageRequestState import org.thoughtcrime.securesms.mms.AttachmentManager import org.thoughtcrime.securesms.mms.AudioSlide import org.thoughtcrime.securesms.mms.GifSlide @@ -1141,7 +1140,7 @@ class ConversationFragment : var inputDisabled = true when { inputReadyState.isClientExpired || inputReadyState.isUnauthorized -> disabledInputView.showAsExpiredOrUnauthorized(inputReadyState.isClientExpired, inputReadyState.isUnauthorized) - inputReadyState.messageRequestState != MessageRequestState.NONE && inputReadyState.messageRequestState != MessageRequestState.NONE_HIDDEN -> disabledInputView.showAsMessageRequest(inputReadyState.conversationRecipient, inputReadyState.messageRequestState) + !inputReadyState.messageRequestState.isAccepted -> disabledInputView.showAsMessageRequest(inputReadyState.conversationRecipient, inputReadyState.messageRequestState) inputReadyState.isActiveGroup == false -> disabledInputView.showAsNoLongerAMember() inputReadyState.isRequestingMember == true -> disabledInputView.showAsRequestingMember() inputReadyState.isAnnouncementGroup == true && inputReadyState.isAdmin == false -> disabledInputView.showAsAnnouncementGroupAdminsOnly() @@ -2099,6 +2098,128 @@ class ConversationFragment : composeText.clearFocus() } + //region Message Request Helpers + + @SuppressLint("CheckResult") + private fun onReportSpam() { + val recipient = viewModel.recipientSnapshot + if (recipient == null) { + Log.w(TAG, "[onBlockClicked] No recipient!") + return + } + + BlockUnblockDialog.showReportSpamFor( + requireContext(), + lifecycle, + recipient, + { + messageRequestViewModel + .onReportSpam() + .doOnSubscribe { binding.conversationDisabledInput.showBusy() } + .doOnTerminate { binding.conversationDisabledInput.hideBusy() } + .subscribeBy { + Log.d(TAG, "report spam complete") + toast(R.string.ConversationFragment_reported_as_spam) + } + }, + if (recipient.isBlocked) { + null + } else { + Runnable { + messageRequestViewModel + .onBlockAndReportSpam() + .doOnSubscribe { binding.conversationDisabledInput.showBusy() } + .doOnTerminate { binding.conversationDisabledInput.hideBusy() } + .subscribeBy { result -> + when (result) { + is Result.Success -> { + Log.d(TAG, "report spam complete") + toast(R.string.ConversationFragment_reported_as_spam_and_blocked) + } + is Result.Failure -> { + Log.d(TAG, "report spam failed ${result.failure}") + toast(GroupErrors.getUserDisplayMessage(result.failure)) + } + } + } + } + } + ) + } + + @SuppressLint("CheckResult") + private fun onBlock() { + val recipient = viewModel.recipientSnapshot + if (recipient == null) { + Log.w(TAG, "[onBlockClicked] No recipient!") + return + } + + BlockUnblockDialog.showBlockFor( + requireContext(), + lifecycle, + recipient + ) { + messageRequestViewModel + .onBlock() + .subscribeWithShowProgress("block") + } + } + + @SuppressLint("CheckResult") + private fun onUnblock() { + val recipient = viewModel.recipientSnapshot + if (recipient == null) { + Log.w(TAG, "[onUnblockClicked] No recipient!") + return + } + + BlockUnblockDialog.showUnblockFor( + requireContext(), + lifecycle, + recipient + ) { + messageRequestViewModel + .onUnblock() + .subscribeWithShowProgress("unblock") + } + } + + private fun onMessageRequestAccept() { + messageRequestViewModel + .onAccept() + .subscribeWithShowProgress("accept message request") + .addTo(disposables) + } + + private fun onDeleteConversation() { + val recipient = viewModel.recipientSnapshot + if (recipient == null) { + Log.w(TAG, "[onDeleteConversation] No recipient!") + return + } + + ConversationDialogs.displayDeleteDialog(requireContext(), recipient) { + messageRequestViewModel + .onDelete() + .subscribeWithShowProgress("delete message request") + } + } + + private fun Single>.subscribeWithShowProgress(logMessage: String): Disposable { + return doOnSubscribe { binding.conversationDisabledInput.showBusy() } + .doOnTerminate { binding.conversationDisabledInput.hideBusy() } + .subscribeBy { result -> + when (result) { + is Result.Success -> Log.d(TAG, "$logMessage complete") + is Result.Failure -> { + Log.d(TAG, "$logMessage failed ${result.failure}") + toast(GroupErrors.getUserDisplayMessage(result.failure)) + } + } + } + } + private inner class BackPressedDelegate : OnBackPressedCallback(true) { override fun handleOnBackPressed() { Log.d(TAG, "onBackPressed()") @@ -2115,6 +2236,8 @@ class ConversationFragment : } } + // endregion + //region Message action handling private fun handleReplyToMessage(conversationMessage: ConversationMessage) { @@ -2983,6 +3106,31 @@ class ConversationFragment : CommunicationActions.startVideoCall(this@ConversationFragment, callLinkRootKey) } + override fun onShowSafetyTips(forGroup: Boolean) { + SafetyTipsBottomSheetDialog.show(childFragmentManager, forGroup) + } + + override fun onReportSpamLearnMoreClicked() { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.ConversationFragment_reported_spam) + .setMessage(R.string.ConversationFragment_reported_spam_message) + .setPositiveButton(android.R.string.ok, null) + .show() + } + + override fun onMessageRequestAcceptOptionsClicked() { + val recipient: Recipient? = viewModel.recipientSnapshot + + if (recipient != null) { + MaterialAlertDialogBuilder(requireContext()) + .setMessage(getString(R.string.ConversationFragment_you_accepted_a_message_request_from_s, recipient.getDisplayName(requireContext()))) + .setPositiveButton(R.string.ConversationFragment_block) { _, _ -> onBlock() } + .setNegativeButton(R.string.ConversationFragment_report_spam) { _, _ -> onReportSpam() } + .setNeutralButton(R.string.ConversationFragment__cancel, null) + .show() + } + } + private fun MessageRecord.getAudioUriForLongClick(): Uri? { val playbackState = getVoiceNoteMediaController().voiceNotePlaybackState.value if (playbackState == null || !playbackState.isPlaying) { @@ -3012,7 +3160,7 @@ class ConversationFragment : hasActiveGroupCall = groupCallViewModel.hasOngoingGroupCallSnapshot, distributionType = args.distributionType, threadId = args.threadId, - isInMessageRequest = viewModel.hasMessageRequestState, + messageRequestState = viewModel.messageRequestState, isInBubble = args.conversationScreenType.isInBubble ) } @@ -3240,6 +3388,26 @@ class ConversationFragment : override fun handleFormatText(id: Int) { composeText.handleFormatText(id) } + + override fun handleBlock() { + onBlock() + } + + override fun handleUnblock() { + onUnblock() + } + + override fun handleReportSpam() { + onReportSpam() + } + + override fun handleMessageRequestAccept() { + onMessageRequestAccept() + } + + override fun handleDeleteConversation() { + onDeleteConversation() + } } private inner class OnReactionsSelectedListener : ConversationReactionOverlay.OnReactionSelectedListener { @@ -3551,66 +3719,23 @@ class ConversationFragment : } override fun onAcceptMessageRequestClicked() { - messageRequestViewModel - .onAccept() - .subscribeWithShowProgress("accept message request") - .addTo(disposables) + onMessageRequestAccept() } - override fun onDeleteGroupClicked() { - val recipient = viewModel.recipientSnapshot - if (recipient == null) { - Log.w(TAG, "[onDeleteGroupClicked] No recipient!") - return - } - - ConversationDialogs.displayDeleteDialog(requireContext(), recipient) { - messageRequestViewModel - .onDelete() - .subscribeWithShowProgress("delete message request") - } + override fun onDeleteClicked() { + onDeleteConversation() } override fun onBlockClicked() { - val recipient = viewModel.recipientSnapshot - if (recipient == null) { - Log.w(TAG, "[onBlockClicked] No recipient!") - return - } - - BlockUnblockDialog.showBlockAndReportSpamFor( - requireContext(), - lifecycle, - recipient, - { - messageRequestViewModel - .onBlock() - .subscribeWithShowProgress("block") - }, - { - messageRequestViewModel - .onBlockAndReportSpam() - .subscribeWithShowProgress("block") - } - ) + onBlock() } override fun onUnblockClicked() { - val recipient = viewModel.recipientSnapshot - if (recipient == null) { - Log.w(TAG, "[onUnblockClicked] No recipient!") - return - } + onUnblock() + } - BlockUnblockDialog.showUnblockFor( - requireContext(), - lifecycle, - recipient - ) { - messageRequestViewModel - .onUnblock() - .subscribeWithShowProgress("unblock") - } + override fun onReportSpamClicked() { + onReportSpam() } override fun onInviteToSignal(recipient: Recipient) { @@ -3625,20 +3750,6 @@ class ConversationFragment : override fun onUnmuteReleaseNotesChannel() { viewModel.muteConversation(0L) } - - private fun Single>.subscribeWithShowProgress(logMessage: String): Disposable { - return doOnSubscribe { binding.conversationDisabledInput.showBusy() } - .doOnTerminate { binding.conversationDisabledInput.hideBusy() } - .subscribeBy { result -> - when (result) { - is Result.Success -> Log.d(TAG, "$logMessage complete") - is Result.Failure -> { - Log.d(TAG, "$logMessage failed ${result.failure}") - toast(GroupErrors.getUserDisplayMessage(result.failure)) - } - } - } - } } //endregion diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt index 572491c8c4..980b27c5c1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt @@ -366,7 +366,7 @@ class ConversationRepository( fun getRequestReviewState(recipient: Recipient, group: GroupRecord?, messageRequest: MessageRequestState): Single { return Single.fromCallable { - if (group == null && messageRequest != MessageRequestState.INDIVIDUAL) { + if (group == null && messageRequest.state != MessageRequestState.State.INDIVIDUAL) { return@fromCallable RequestReviewState() } 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 062bc058e1..4832fe438b 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 @@ -132,9 +132,11 @@ class ConversationViewModel( private val _inputReadyState: Observable val inputReadyState: Observable - private val hasMessageRequestStateSubject: BehaviorSubject = BehaviorSubject.createDefault(false) + private val hasMessageRequestStateSubject: BehaviorSubject = BehaviorSubject.createDefault(MessageRequestState()) val hasMessageRequestState: Boolean - get() = hasMessageRequestStateSubject.value ?: false + get() = hasMessageRequestStateSubject.value?.state != MessageRequestState.State.NONE + val messageRequestState: MessageRequestState + get() = hasMessageRequestStateSubject.value ?: MessageRequestState() private val refreshReminder: Subject = PublishSubject.create() val reminder: Observable> @@ -239,7 +241,7 @@ class ConversationViewModel( isUnauthorized = TextSecurePreferences.isUnauthorizedReceived(ApplicationDependencies.getApplication()) ) }.doOnNext { - hasMessageRequestStateSubject.onNext(it.messageRequestState != MessageRequestState.NONE) + hasMessageRequestStateSubject.onNext(it.messageRequestState) } inputReadyState = _inputReadyState.observeOn(AndroidSchedulers.mainThread()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DisabledInputView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DisabledInputView.kt index 3acc0d5c9f..bee0670845 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DisabledInputView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DisabledInputView.kt @@ -17,7 +17,6 @@ import androidx.core.content.ContextCompat import com.google.android.material.button.MaterialButton import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.messagerequests.MessageRequestState -import org.thoughtcrime.securesms.messagerequests.MessageRequestViewModel import org.thoughtcrime.securesms.messagerequests.MessageRequestsBottomView import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.util.SpanUtil @@ -80,13 +79,14 @@ class DisabledInputView @JvmOverloads constructor( existingView = messageRequestView, create = { MessageRequestsBottomView(context) }, bind = { - setMessageData(MessageRequestViewModel.MessageData(recipient, messageRequestState)) + setMessageRequestData(recipient, messageRequestState) setWallpaperEnabled(recipient.hasWallpaper()) setAcceptOnClickListener { listener?.onAcceptMessageRequestClicked() } - setDeleteOnClickListener { listener?.onDeleteGroupClicked() } + setDeleteOnClickListener { listener?.onDeleteClicked() } setBlockOnClickListener { listener?.onBlockClicked() } setUnblockOnClickListener { listener?.onUnblockClicked() } + setReportOnClickListener { listener?.onReportSpamClicked() } } ) } @@ -226,10 +226,11 @@ class DisabledInputView @JvmOverloads constructor( fun onCancelGroupRequestClicked() fun onShowAdminsBottomSheetDialog() fun onAcceptMessageRequestClicked() - fun onDeleteGroupClicked() + fun onDeleteClicked() fun onBlockClicked() fun onUnblockClicked() fun onInviteToSignal(recipient: Recipient) fun onUnmuteReleaseNotesChannel() + fun onReportSpamClicked() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageRequestViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageRequestViewModel.kt index cfdd8704b7..9e330eeed3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageRequestViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageRequestViewModel.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.conversation.v2 import androidx.lifecycle.ViewModel import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Single import org.signal.core.util.Result import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason @@ -57,6 +58,14 @@ class MessageRequestViewModel( .observeOn(AndroidSchedulers.mainThread()) } + fun onReportSpam(): Completable { + return recipientId + .flatMapCompletable { recipientId -> + messageRequestRepository.reportSpamMessageRequest(recipientId, threadId) + } + .observeOn(AndroidSchedulers.mainThread()) + } + fun onBlockAndReportSpam(): Single> { return recipientId .flatMap { recipientId -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/SafetyTipsBottomSheetDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/SafetyTipsBottomSheetDialog.kt new file mode 100644 index 0000000000..a5d7ce0d87 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/SafetyTipsBottomSheetDialog.kt @@ -0,0 +1,277 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation.v2 + +import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.rememberNestedScrollInteropConnection +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.os.bundleOf +import androidx.fragment.app.FragmentManager +import kotlinx.coroutines.launch +import org.signal.core.ui.BottomSheets +import org.signal.core.ui.Buttons +import org.signal.core.ui.theme.SignalTheme +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment + +/** + * Shows tips about typical spam and fraud messages. + */ +class SafetyTipsBottomSheetDialog : ComposeBottomSheetDialogFragment() { + companion object { + private const val FOR_GROUP_ARG = "for_group" + + fun show(fragmentManager: FragmentManager, forGroup: Boolean) { + SafetyTipsBottomSheetDialog() + .apply { + arguments = bundleOf( + FOR_GROUP_ARG to forGroup + ) + } + .show(fragmentManager, "SAFETY_TIPS") + } + } + + override val peekHeightPercentage: Float = 1f + + @Composable + override fun SheetContent() { + val nestedScrollInterop = rememberNestedScrollInteropConnection() + SafetyTipsContent( + forGroup = requireArguments().getBoolean(FOR_GROUP_ARG, false), + modifier = Modifier.nestedScroll(nestedScrollInterop) + ) + } +} + +data class SafetyTipData( + @DrawableRes val heroImage: Int, + @StringRes val titleText: Int, + @StringRes val messageText: Int +) + +private val tips = listOf( + SafetyTipData(heroImage = R.drawable.safety_tip1, titleText = R.string.SafetyTips_tip1_title, messageText = R.string.SafetyTips_tip1_message), + SafetyTipData(heroImage = R.drawable.safety_tip2, titleText = R.string.SafetyTips_tip2_title, messageText = R.string.SafetyTips_tip2_message), + SafetyTipData(heroImage = R.drawable.safety_tip3, titleText = R.string.SafetyTips_tip3_title, messageText = R.string.SafetyTips_tip3_message), + SafetyTipData(heroImage = R.drawable.safety_tip4, titleText = R.string.SafetyTips_tip4_title, messageText = R.string.SafetyTips_tip4_message) +) + +@Preview +@Composable +private fun SafetyTipsContentPreview() { + SignalTheme { + Surface { + SafetyTipsContent() + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun SafetyTipsContent(forGroup: Boolean = false, modifier: Modifier = Modifier) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth() + ) { + BottomSheets.Handle() + } + + val size = remember { tips.size } + val pagerState = rememberPagerState( + pageCount = { size } + ) + val scrollState = rememberScrollState() + + Column( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = modifier + .fillMaxWidth() + .weight(weight = 1f, fill = false) + .padding(top = 22.dp) + .verticalScroll(state = scrollState) + ) { + Text( + text = stringResource(id = R.string.SafetyTips_title), + style = MaterialTheme.typography.headlineMedium.copy(textAlign = TextAlign.Center), + modifier = Modifier + .padding(start = 24.dp, end = 24.dp, bottom = 4.dp, top = 26.dp) + .fillMaxWidth() + ) + + Text( + text = if (forGroup) stringResource(id = R.string.SafetyTips_subtitle_group) else stringResource(id = R.string.SafetyTips_subtitle_individual), + style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurfaceVariant), + modifier = Modifier + .padding(start = 36.dp, end = 36.dp) + .fillMaxWidth() + ) + + HorizontalPager( + state = pagerState, + beyondBoundsPageCount = size, + modifier = Modifier.padding(top = 24.dp) + ) { + SafetyTip(tips[it]) + } + + Row( + Modifier + .fillMaxWidth() + .padding(top = 20.dp), + horizontalArrangement = Arrangement.Center + ) { + repeat(pagerState.pageCount) { iteration -> + val color = if (pagerState.currentPage == iteration) { + MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f) + } else { + MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.3f) + } + Box( + modifier = Modifier + .padding(3.dp) + .clip(CircleShape) + .background(color) + .size(8.dp) + ) + } + } + } + + Surface( + shadowElevation = if (scrollState.canScrollForward) 8.dp else 0.dp, + modifier = Modifier.fillMaxWidth(), + color = SignalTheme.colors.colorSurface1, + contentColor = MaterialTheme.colorScheme.onSurface + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .padding(start = 24.dp, end = 24.dp, bottom = 36.dp, top = 24.dp) + .fillMaxWidth() + ) { + val coroutineScope = rememberCoroutineScope() + + TextButton( + onClick = { + coroutineScope.launch { + pagerState.animateScrollToPage(pagerState.currentPage - 1) + } + }, + enabled = pagerState.currentPage > 0, + modifier = Modifier + ) { + Text(text = stringResource(id = R.string.SafetyTips_previous_tip)) + } + + Buttons.LargeTonal( + onClick = { + coroutineScope.launch { + pagerState.animateScrollToPage(pagerState.currentPage + 1) + } + }, + enabled = pagerState.currentPage + 1 < pagerState.pageCount + ) { + Text(text = stringResource(id = R.string.SafetyTips_next_tip)) + } + } + } + } +} + +@Preview(name = "Light Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun SafetyTipPreview() { + SignalTheme { + Surface { + SafetyTip(tips[0]) + } + } +} + +@Composable +private fun SafetyTip(safetyTip: SafetyTipData) { + Surface( + shape = RoundedCornerShape(18.dp), + color = colorResource(id = R.color.safety_tip_background), + contentColor = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .fillMaxWidth() + .padding(start = 24.dp, end = 24.dp) + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + ) { + Surface( + shape = RoundedCornerShape(12.dp), + color = colorResource(id = R.color.safety_tip_image_background), + modifier = Modifier + .padding(12.dp) + .fillMaxWidth() + ) { + Image( + painter = painterResource(id = safetyTip.heroImage), + contentDescription = null, + modifier = Modifier + .padding(16.dp) + ) + } + + Text( + text = stringResource(id = safetyTip.titleText), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier + .padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 4.dp) + ) + + Text( + text = stringResource(id = safetyTip.messageText), + style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center), + modifier = Modifier + .padding(start = 24.dp, end = 24.dp, bottom = 24.dp) + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/data/ConversationDataSource.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/data/ConversationDataSource.kt index ac470e45d8..317e4d6907 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/data/ConversationDataSource.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/data/ConversationDataSource.kt @@ -15,7 +15,6 @@ import org.thoughtcrime.securesms.conversation.ConversationMessage import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory import org.thoughtcrime.securesms.database.MessageTable import org.thoughtcrime.securesms.database.SignalDatabase -import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord.NoGroupsInCommon import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord.RemovedContactHidden import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord.UniversalExpireTimerUpdate import org.thoughtcrime.securesms.database.model.MessageRecord @@ -72,7 +71,6 @@ class ConversationDataSource( val startTime = System.currentTimeMillis() val size: Int = getSizeInternal() + THREAD_HEADER_COUNT + - messageRequestData.includeWarningUpdateMessage().toInt() + messageRequestData.isHidden.toInt() + showUniversalExpireTimerUpdate.toInt() @@ -108,10 +106,6 @@ class ConversationDataSource( } } - if (messageRequestData.includeWarningUpdateMessage() && (start + length >= totalSize)) { - records.add(NoGroupsInCommon(threadId, messageRequestData.isGroup)) - } - if (messageRequestData.isHidden && (start + length >= totalSize)) { records.add(RemovedContactHidden(threadId)) } 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 dff5f70d8d..a9b7bb417e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.kt @@ -6,6 +6,7 @@ import android.database.Cursor import android.text.TextUtils import androidx.annotation.WorkerThread import androidx.core.content.contentValuesOf +import okio.ByteString import org.intellij.lang.annotations.Language import org.signal.core.util.SqlUtil import org.signal.core.util.SqlUtil.appendArg @@ -32,6 +33,7 @@ import org.signal.core.util.withinTransaction import org.signal.libsignal.zkgroup.groups.GroupMasterKey import org.signal.storageservice.protos.groups.Member import org.signal.storageservice.protos.groups.local.DecryptedGroup +import org.signal.storageservice.protos.groups.local.DecryptedPendingMember import org.thoughtcrime.securesms.contacts.paged.ContactSearchSortOrder import org.thoughtcrime.securesms.contacts.paged.collections.ContactSearchIterator import org.thoughtcrime.securesms.crypto.SenderKeyUtil @@ -57,6 +59,7 @@ import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPoin import org.whispersystems.signalservice.api.push.DistributionId import org.whispersystems.signalservice.api.push.ServiceId import org.whispersystems.signalservice.api.push.ServiceId.ACI +import org.whispersystems.signalservice.api.push.ServiceId.ACI.Companion.parseOrNull import org.whispersystems.signalservice.api.push.ServiceId.PNI import java.io.Closeable import java.security.SecureRandom @@ -639,6 +642,27 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT } } + fun getGroupInviter(groupId: GroupId): Recipient? { + val groupRecord: Optional = getGroup(groupId) + + if (groupRecord.isPresent && groupRecord.get().isV2Group) { + val pendingMembers: List = groupRecord.get().requireV2GroupProperties().decryptedGroup.pendingMembers + val invitedByAci: ByteString? = DecryptedGroupUtil.findPendingByServiceId(pendingMembers, Recipient.self().requireAci()) + .or { DecryptedGroupUtil.findPendingByServiceId(pendingMembers, Recipient.self().requirePni()) } + .map { it.addedByAci } + .orElse(null) + + if (invitedByAci != null) { + val serviceId: ServiceId? = parseOrNull(invitedByAci) + if (serviceId != null) { + return Recipient.externalPush(serviceId) + } + } + } + + return null + } + @CheckReturnValue fun create(groupId: GroupId.V1, title: String?, members: Collection, avatar: SignalServiceAttachmentPointer?): Boolean { if (groupExists(groupId.deriveV2MigrationGroupId())) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt index b93b883444..ce9be8a6b8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt @@ -405,6 +405,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat $LATEST_REVISION_ID IS NULL AND $TYPE & ${MessageTypes.KEY_EXCHANGE_IDENTITY_DEFAULT_BIT} = 0 AND $TYPE & ${MessageTypes.KEY_EXCHANGE_IDENTITY_VERIFIED_BIT} = 0 AND + $TYPE & ${MessageTypes.SPECIAL_TYPES_MASK} != ${MessageTypes.SPECIAL_TYPE_REPORTED_SPAM} AND + $TYPE & ${MessageTypes.SPECIAL_TYPES_MASK} != ${MessageTypes.SPECIAL_TYPE_MESSAGE_REQUEST_ACCEPTED} AND $TYPE NOT IN ( ${MessageTypes.PROFILE_CHANGE_TYPE}, ${MessageTypes.GV1_MIGRATION_TYPE}, @@ -1728,7 +1730,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat $TYPE != ${MessageTypes.CHANGE_NUMBER_TYPE} AND $TYPE != ${MessageTypes.SMS_EXPORT_TYPE} AND $TYPE != ${MessageTypes.BOOST_REQUEST_TYPE} AND - $TYPE & ${MessageTypes.GROUP_V2_LEAVE_BITS} != ${MessageTypes.GROUP_V2_LEAVE_BITS} + $TYPE & ${MessageTypes.GROUP_V2_LEAVE_BITS} != ${MessageTypes.GROUP_V2_LEAVE_BITS} AND + $TYPE & ${MessageTypes.SPECIAL_TYPES_MASK} != ${MessageTypes.SPECIAL_TYPE_REPORTED_SPAM} AND + $TYPE & ${MessageTypes.SPECIAL_TYPES_MASK} != ${MessageTypes.SPECIAL_TYPE_MESSAGE_REQUEST_ACCEPTED} ) """ @@ -2388,6 +2392,18 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat sentTimeMillis = timestamp, expiresIn = expiresIn ) + } else if (MessageTypes.isReportedSpam(outboxType)) { + OutgoingMessage.reportSpamMessage( + threadRecipient = threadRecipient, + sentTimeMillis = timestamp, + expiresIn = expiresIn + ) + } else if (MessageTypes.isMessageRequestAccepted(outboxType)) { + OutgoingMessage.messageRequestAcceptMessage( + threadRecipient = threadRecipient, + sentTimeMillis = timestamp, + expiresIn = expiresIn + ) } else { val giftBadge: GiftBadge? = if (body != null && MessageTypes.isGiftBadge(outboxType)) { GiftBadge.ADAPTER.decode(Base64.decode(body)) @@ -2552,7 +2568,15 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat val isNotStoryGroupReply = retrieved.parentStoryId == null || !retrieved.parentStoryId.isGroupReply() - if (!MessageTypes.isPaymentsActivated(type) && !MessageTypes.isPaymentsRequestToActivate(type) && !MessageTypes.isExpirationTimerUpdate(type) && !retrieved.storyType.isStory && isNotStoryGroupReply && !silent) { + if (!MessageTypes.isPaymentsActivated(type) && + !MessageTypes.isPaymentsRequestToActivate(type) && + !MessageTypes.isReportedSpam(type) && + !MessageTypes.isMessageRequestAccepted(type) && + !MessageTypes.isExpirationTimerUpdate(type) && + !retrieved.storyType.isStory && + isNotStoryGroupReply && + !silent + ) { val incrementUnreadMentions = retrieved.mentions.isNotEmpty() && retrieved.mentions.any { it.recipientId == Recipient.self().id } threads.incrementUnread(threadId, 1, if (incrementUnreadMentions) 1 else 0) ThreadUpdateJob.enqueue(threadId) @@ -2782,6 +2806,22 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat hasSpecialType = true } + if (message.isReportSpam) { + if (hasSpecialType) { + throw MmsException("Cannot insert message with multiple special types.") + } + type = type or MessageTypes.SPECIAL_TYPE_REPORTED_SPAM + hasSpecialType = true + } + + if (message.isMessageRequestAccept) { + if (hasSpecialType) { + throw MmsException("Cannot insert message with multiple special types.") + } + type = type or MessageTypes.SPECIAL_TYPE_MESSAGE_REQUEST_ACCEPTED + hasSpecialType = true + } + val earlyDeliveryReceipts: Map = earlyDeliveryReceiptCache.remove(message.sentTimeMillis) if (earlyDeliveryReceipts.isNotEmpty()) { @@ -3533,6 +3573,13 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat .run() } + fun hasReportSpamMessage(threadId: Long): Boolean { + return readableDatabase + .exists(TABLE_NAME) + .where("$THREAD_ID = $threadId AND ($TYPE & ${MessageTypes.SPECIAL_TYPES_MASK}) = ${MessageTypes.SPECIAL_TYPE_REPORTED_SPAM}") + .run() + } + private val outgoingInsecureMessageClause = "($TYPE & ${MessageTypes.BASE_TYPE_MASK}) = ${MessageTypes.BASE_SENT_TYPE} AND NOT ($TYPE & ${MessageTypes.SECURE_MESSAGE_BIT})" private val outgoingSecureMessageClause = "($TYPE & ${MessageTypes.BASE_TYPE_MASK}) = ${MessageTypes.BASE_SENT_TYPE} AND ($TYPE & ${MessageTypes.SECURE_MESSAGE_BIT or MessageTypes.PUSH_MESSAGE_BIT})" @@ -4011,6 +4058,33 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat .take(limit) } + fun getGroupReportSpamMessageServerData(threadId: Long, inviter: RecipientId, timestamp: Long, limit: Int): List { + val data: MutableList = ArrayList() + + val incomingGroupUpdateClause = "($TYPE & ${MessageTypes.BASE_TYPE_MASK}) = ${MessageTypes.BASE_INBOX_TYPE} AND ($TYPE & ${MessageTypes.GROUP_UPDATE_BIT}) != 0" + + readableDatabase + .select(FROM_RECIPIENT_ID, SERVER_GUID, DATE_RECEIVED) + .from(TABLE_NAME) + .where("$FROM_RECIPIENT_ID = ? AND $THREAD_ID = ? AND $DATE_RECEIVED <= ? AND $incomingGroupUpdateClause", inviter, threadId, timestamp) + .orderBy("$DATE_RECEIVED DESC") + .limit(limit) + .run() + .forEach { cursor -> + val serverGuid: String? = cursor.requireString(SERVER_GUID) + + if (serverGuid != null && serverGuid.isNotEmpty()) { + data += ReportSpamData( + recipientId = RecipientId.from(cursor.requireLong(FROM_RECIPIENT_ID)), + serverGuid = serverGuid, + dateReceived = cursor.requireLong(DATE_RECEIVED) + ) + } + } + + return data + } + @Throws(NoSuchMessageException::class) private fun getMessageExportState(messageId: MessageId): MessageExportState { return readableDatabase diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTypes.java b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTypes.java index d1ff8aead8..d4a184cbfb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTypes.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTypes.java @@ -113,6 +113,8 @@ public interface MessageTypes { long SPECIAL_TYPE_GIFT_BADGE = 0x200000000L; long SPECIAL_TYPE_PAYMENTS_NOTIFICATION = 0x300000000L; long SPECIAL_TYPE_PAYMENTS_ACTIVATE_REQUEST = 0x400000000L; + long SPECIAL_TYPE_REPORTED_SPAM = 0x500000000L; + long SPECIAL_TYPE_MESSAGE_REQUEST_ACCEPTED = 0x600000000L; long SPECIAL_TYPE_PAYMENTS_ACTIVATED = 0x800000000L; long IGNORABLE_TYPESMASK_WHEN_COUNTING = END_SESSION_BIT | KEY_EXCHANGE_IDENTITY_UPDATE_BIT | KEY_EXCHANGE_IDENTITY_VERIFIED_BIT; @@ -137,6 +139,14 @@ public interface MessageTypes { return (type & SPECIAL_TYPES_MASK) == SPECIAL_TYPE_PAYMENTS_ACTIVATED; } + static boolean isReportedSpam(long type) { + return (type & SPECIAL_TYPES_MASK) == SPECIAL_TYPE_REPORTED_SPAM; + } + + static boolean isMessageRequestAccepted(long type) { + return (type & SPECIAL_TYPES_MASK) == SPECIAL_TYPE_MESSAGE_REQUEST_ACCEPTED; + } + static boolean isDraftMessageType(long type) { return (type & BASE_TYPE_MASK) == BASE_DRAFT_TYPE; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java index 2193327e0d..a8a7fc1e41 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java @@ -239,4 +239,12 @@ public abstract class DisplayRecord { public boolean isPaymentsActivated() { return MessageTypes.isPaymentsActivated(type); } + + public boolean isReportedSpam() { + return MessageTypes.isReportedSpam(type); + } + + public boolean isMessageRequestAccepted() { + return MessageTypes.isMessageRequestAccepted(type); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/InMemoryMessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/InMemoryMessageRecord.java index d7b988f8b0..3f332eb576 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/InMemoryMessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/InMemoryMessageRecord.java @@ -83,45 +83,6 @@ public class InMemoryMessageRecord extends MessageRecord { return 0; } - /** - * Warning message to show during message request state if you do not have groups in common - * with an individual or do not know anyone in the group. - */ - public static final class NoGroupsInCommon extends InMemoryMessageRecord { - private final boolean isGroup; - - public NoGroupsInCommon(long threadId, boolean isGroup) { - super(NO_GROUPS_IN_COMMON_ID, "", Recipient.UNKNOWN, threadId, 0); - this.isGroup = isGroup; - } - - @Override - public @Nullable UpdateDescription getUpdateDisplayBody(@NonNull Context context, @Nullable Consumer recipientClickHandler) { - return UpdateDescription.staticDescription(context.getString(isGroup ? R.string.ConversationUpdateItem_no_contacts_in_this_group_review_requests_carefully - : R.string.ConversationUpdateItem_no_groups_in_common_review_requests_carefully), - R.drawable.symbol_info_compact_16); - } - - @Override - public boolean isUpdate() { - return true; - } - - @Override - public boolean showActionButton() { - return true; - } - - public boolean isGroup() { - return isGroup; - } - - @Override - public @StringRes int getActionButtonText() { - return R.string.ConversationUpdateItem_learn_more; - } - } - public static final class RemovedContactHidden extends InMemoryMessageRecord { public RemovedContactHidden(long threadId) { 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 0f47dc974b..afad456370 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 @@ -271,6 +271,10 @@ public abstract class MessageRecord extends DisplayRecord { } else if (isPaymentsActivated()) { return isOutgoing() ? staticUpdateDescription(context.getString(R.string.MessageRecord_you_activated_payments), R.drawable.ic_card_activate_payments) : fromRecipient(getFromRecipient(), r -> context.getString(R.string.MessageRecord_can_accept_payments, r.getShortDisplayName(context)), R.drawable.ic_card_activate_payments); + } else if (isReportedSpam()) { + return staticUpdateDescription(context.getString(R.string.MessageRecord_reported_as_spam), R.drawable.symbol_spam_16); + } else if (isMessageRequestAccepted()) { + return staticUpdateDescription(context.getString(R.string.MessageRecord_you_accepted_the_message_request), R.drawable.symbol_thread_16); } return null; @@ -632,7 +636,7 @@ public abstract class MessageRecord extends DisplayRecord { isEndSession() || isIdentityUpdate() || isIdentityVerified() || isIdentityDefault() || isProfileChange() || isGroupV1MigrationEvent() || isChatSessionRefresh() || isBadDecryptType() || isChangeNumber() || isBoostRequest() || isThreadMergeEventType() || isSmsExportType() || isSessionSwitchoverEventType() || - isPaymentsRequestToActivate() || isPaymentsActivated(); + isPaymentsRequestToActivate() || isPaymentsActivated() || isReportedSpam() || isMessageRequestAccepted(); } public boolean isMediaPending() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java index 57a96232fc..09de6bca61 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java @@ -200,11 +200,12 @@ public final class GroupManager { @Nullable GroupSecretParams groupSecretParams, int revision, long timestamp, - @Nullable byte[] signedGroupChange) + @Nullable byte[] signedGroupChange, + @Nullable String serverGuid) throws GroupChangeBusyException, IOException, GroupNotAMemberException { try (GroupManagerV2.GroupUpdater updater = new GroupManagerV2(context).updater(groupMasterKey)) { - return updater.updateLocalToServerRevision(revision, timestamp, groupRecord, groupSecretParams, signedGroupChange); + return updater.updateLocalToServerRevision(revision, timestamp, groupRecord, groupSecretParams, signedGroupChange, serverGuid); } } 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 2da1d90b40..98a9922478 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java @@ -805,11 +805,16 @@ final class GroupManagerV2 { } @WorkerThread - GroupsV2StateProcessor.GroupUpdateResult updateLocalToServerRevision(int revision, long timestamp, @NonNull Optional localRecord, @Nullable GroupSecretParams groupSecretParams, @Nullable byte[] signedGroupChange) + GroupsV2StateProcessor.GroupUpdateResult updateLocalToServerRevision(int revision, + long timestamp, + @NonNull Optional localRecord, + @Nullable GroupSecretParams groupSecretParams, + @Nullable byte[] signedGroupChange, + @Nullable String serverGuid) throws IOException, GroupNotAMemberException { return new GroupsV2StateProcessor(context).forGroup(serviceIds, groupMasterKey, groupSecretParams) - .updateLocalGroupToRevision(revision, timestamp, localRecord, getDecryptedGroupChange(signedGroupChange)); + .updateLocalGroupToRevision(revision, timestamp, localRecord, getDecryptedGroupChange(signedGroupChange), serverGuid); } @WorkerThread 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 748e42a420..d4178b86a9 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 @@ -238,10 +238,10 @@ public class GroupsV2StateProcessor { updateLocalDatabaseGroupState(inputGroupState, newLocalState); if (localState.revision == GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION) { info("Inserting single update message for restore placeholder"); - profileAndMessageHelper.insertUpdateMessages(timestamp, null, Collections.singleton(new LocalGroupLogEntry(newLocalState, null))); + profileAndMessageHelper.insertUpdateMessages(timestamp, null, Collections.singleton(new LocalGroupLogEntry(newLocalState, null)), null); } else { info("Inserting force update messages"); - profileAndMessageHelper.insertUpdateMessages(timestamp, localState, advanceGroupStateResult.getProcessedLogEntries()); + profileAndMessageHelper.insertUpdateMessages(timestamp, localState, advanceGroupStateResult.getProcessedLogEntries(), null); } profileAndMessageHelper.persistLearnedProfileKeys(inputGroupState); @@ -259,7 +259,7 @@ public class GroupsV2StateProcessor { @Nullable DecryptedGroupChange signedGroupChange) throws IOException, GroupNotAMemberException { - return updateLocalGroupToRevision(revision, timestamp, groupDatabase.getGroup(groupId), signedGroupChange); + return updateLocalGroupToRevision(revision, timestamp, groupDatabase.getGroup(groupId), signedGroupChange, null); } /** @@ -271,7 +271,8 @@ public class GroupsV2StateProcessor { public GroupUpdateResult updateLocalGroupToRevision(final int revision, final long timestamp, @NonNull Optional localRecord, - @Nullable DecryptedGroupChange signedGroupChange) + @Nullable DecryptedGroupChange signedGroupChange, + @Nullable String serverGuid) throws IOException, GroupNotAMemberException { if (localIsAtLeast(localRecord, revision)) { @@ -287,7 +288,6 @@ public class GroupsV2StateProcessor { localState.revision + 1 == signedGroupChange.revision && revision == signedGroupChange.revision) { - if (notInGroupAndNotBeingAdded(localRecord, signedGroupChange) && notHavingInviteRevoked(signedGroupChange)) { warn("Ignoring P2P group change because we're not currently in the group and this change doesn't add us in. Falling back to a server fetch."); } else if (SignalStore.internalValues().gv2IgnoreP2PChanges()) { @@ -306,7 +306,7 @@ public class GroupsV2StateProcessor { if (inputGroupState == null) { try { - return updateLocalGroupFromServerPaged(revision, localState, timestamp, false); + return updateLocalGroupFromServerPaged(revision, localState, timestamp, false, serverGuid); } catch (GroupNotAMemberException e) { if (localState != null && signedGroupChange != null) { try { @@ -346,9 +346,9 @@ public class GroupsV2StateProcessor { updateLocalDatabaseGroupState(inputGroupState, newLocalState); if (localState != null && localState.revision == GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION) { info("Inserting single update message for restore placeholder"); - profileAndMessageHelper.insertUpdateMessages(timestamp, null, Collections.singleton(new LocalGroupLogEntry(newLocalState, null))); + profileAndMessageHelper.insertUpdateMessages(timestamp, null, Collections.singleton(new LocalGroupLogEntry(newLocalState, null)), null); } else { - profileAndMessageHelper.insertUpdateMessages(timestamp, localState, advanceGroupStateResult.getProcessedLogEntries()); + profileAndMessageHelper.insertUpdateMessages(timestamp, localState, advanceGroupStateResult.getProcessedLogEntries(), serverGuid); } profileAndMessageHelper.persistLearnedProfileKeys(inputGroupState); @@ -398,7 +398,7 @@ public class GroupsV2StateProcessor { /** * Using network, attempt to bring the local copy of the group up to the revision specified via paging. */ - private GroupUpdateResult updateLocalGroupFromServerPaged(int revision, DecryptedGroup localState, long timestamp, boolean forceIncludeFirst) throws IOException, GroupNotAMemberException { + private GroupUpdateResult updateLocalGroupFromServerPaged(int revision, DecryptedGroup localState, long timestamp, boolean forceIncludeFirst, @Nullable String serverGuid) throws IOException, GroupNotAMemberException { boolean latestRevisionOnly = revision == LATEST && (localState == null || localState.revision == GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION); info("Paging from server revision: " + (revision == LATEST ? "latest" : revision) + ", latestOnly: " + latestRevisionOnly); @@ -456,7 +456,7 @@ public class GroupsV2StateProcessor { int requestRevision = (revision == LATEST) ? latestServerGroup.getRevision() : revision; if (newLocalRevision < requestRevision) { warn( "Paging again with force first snapshot enabled due to error processing changes. New local revision [" + newLocalRevision + "] hasn't reached our desired level [" + requestRevision + "]"); - return updateLocalGroupFromServerPaged(revision, localState, timestamp, true); + return updateLocalGroupFromServerPaged(revision, localState, timestamp, true, serverGuid); } } @@ -467,7 +467,7 @@ public class GroupsV2StateProcessor { updateLocalDatabaseGroupState(inputGroupState, newLocalState); if (localState == null || localState.revision != GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION) { - timestamp = profileAndMessageHelper.insertUpdateMessages(timestamp, localState, advanceGroupStateResult.getProcessedLogEntries()); + timestamp = profileAndMessageHelper.insertUpdateMessages(timestamp, localState, advanceGroupStateResult.getProcessedLogEntries(), serverGuid); } for (ServerGroupLogEntry entry : inputGroupState.getServerHistory()) { @@ -491,7 +491,7 @@ public class GroupsV2StateProcessor { if (localState != null && localState.revision == GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION) { info("Inserting single update message for restore placeholder"); - profileAndMessageHelper.insertUpdateMessages(timestamp, null, Collections.singleton(new LocalGroupLogEntry(finalState, null))); + profileAndMessageHelper.insertUpdateMessages(timestamp, null, Collections.singleton(new LocalGroupLogEntry(finalState, null)), serverGuid); } profileAndMessageHelper.persistLearnedProfileKeys(profileKeys); @@ -735,7 +735,8 @@ public class GroupsV2StateProcessor { long insertUpdateMessages(long timestamp, @Nullable DecryptedGroup previousGroupState, - Collection processedLogEntries) + Collection processedLogEntries, + @Nullable String serverGuid) { for (LocalGroupLogEntry entry : processedLogEntries) { if (entry.getChange() != null && DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(entry.getChange()) && !DecryptedGroupUtil.changeIsEmpty(entry.getChange())) { @@ -746,7 +747,7 @@ public class GroupsV2StateProcessor { if (entry.getChange() != null && DecryptedGroupUtil.changeIsEmpty(entry.getChange()) && previousGroupState != null) { Log.w(TAG, "Empty group update message seen. Not inserting."); } else { - storeMessage(GroupProtoUtil.createDecryptedGroupV2Context(masterKey, new GroupMutation(previousGroupState, entry.getChange(), entry.getGroup()), null), timestamp); + storeMessage(GroupProtoUtil.createDecryptedGroupV2Context(masterKey, new GroupMutation(previousGroupState, entry.getChange(), entry.getGroup()), null), timestamp, serverGuid); timestamp++; } } @@ -782,7 +783,7 @@ public class GroupsV2StateProcessor { } } - void storeMessage(@NonNull DecryptedGroupV2Context decryptedGroupV2Context, long timestamp) { + void storeMessage(@NonNull DecryptedGroupV2Context decryptedGroupV2Context, long timestamp, @Nullable String serverGuid) { Optional editor = getEditor(decryptedGroupV2Context); boolean outgoing = !editor.isPresent() || aci.equals(editor.get()); @@ -806,7 +807,7 @@ public class GroupsV2StateProcessor { try { MessageTable smsDatabase = SignalDatabase.messages(); RecipientId sender = RecipientId.from(editor.get()); - IncomingMessage groupMessage = IncomingMessage.groupUpdate(sender, timestamp, groupId, decryptedGroupV2Context); + IncomingMessage groupMessage = IncomingMessage.groupUpdate(sender, timestamp, groupId, decryptedGroupV2Context, serverGuid); Optional insertResult = smsDatabase.insertMessageInbox(groupMessage); if (insertResult.isPresent()) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceMessageRequestResponseJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceMessageRequestResponseJob.java index f2fcd40b4f..eb6a1dd5c1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceMessageRequestResponseJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceMessageRequestResponseJob.java @@ -54,7 +54,11 @@ public class MultiDeviceMessageRequestResponseJob extends BaseJob { } public static @NonNull MultiDeviceMessageRequestResponseJob forBlockAndReportSpam(@NonNull RecipientId threadRecipient) { - return new MultiDeviceMessageRequestResponseJob(threadRecipient, Type.BLOCK); + return new MultiDeviceMessageRequestResponseJob(threadRecipient, Type.BLOCK_AND_SPAM); + } + + public static @NonNull MultiDeviceMessageRequestResponseJob forReportSpam(@NonNull RecipientId threadRecipient) { + return new MultiDeviceMessageRequestResponseJob(threadRecipient, Type.SPAM); } private MultiDeviceMessageRequestResponseJob(@NonNull RecipientId threadRecipient, @NonNull Type type) { @@ -135,6 +139,10 @@ public class MultiDeviceMessageRequestResponseJob extends BaseJob { return MessageRequestResponseMessage.Type.BLOCK; case BLOCK_AND_DELETE: return MessageRequestResponseMessage.Type.BLOCK_AND_DELETE; + case SPAM: + return MessageRequestResponseMessage.Type.SPAM; + case BLOCK_AND_SPAM: + return MessageRequestResponseMessage.Type.BLOCK_AND_SPAM; default: return MessageRequestResponseMessage.Type.UNKNOWN; } @@ -151,7 +159,7 @@ public class MultiDeviceMessageRequestResponseJob extends BaseJob { } private enum Type { - UNKNOWN(0), ACCEPT(1), DELETE(2), BLOCK(3), BLOCK_AND_DELETE(4); + UNKNOWN(0), ACCEPT(1), DELETE(2), BLOCK(3), BLOCK_AND_DELETE(4), SPAM(5), BLOCK_AND_SPAM(6); private final int value; diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/ReportSpamJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/ReportSpamJob.java index 0c6b059507..53069b4f9b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/ReportSpamJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/ReportSpamJob.java @@ -20,6 +20,7 @@ import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulRespons import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException; import java.io.IOException; +import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.concurrent.TimeUnit; @@ -72,9 +73,28 @@ public class ReportSpamJob extends BaseJob { return; } - int count = 0; - List reportSpamData = SignalDatabase.messages().getReportSpamMessageServerData(threadId, timestamp, MAX_MESSAGE_COUNT); - SignalServiceAccountManager signalServiceAccountManager = ApplicationDependencies.getSignalServiceAccountManager(); + Recipient threadRecipient = SignalDatabase.threads().getRecipientForThreadId(threadId); + if (threadRecipient == null) { + Log.w(TAG, "No recipient for thread"); + return; + } + + List reportSpamData; + + if (threadRecipient.isGroup()) { + Recipient inviter = SignalDatabase.groups().getGroupInviter(threadRecipient.requireGroupId()); + if (inviter == null) { + Log.w(TAG, "Unable to determine inviter to report"); + return; + } + + reportSpamData = SignalDatabase.messages().getGroupReportSpamMessageServerData(threadId, inviter.getId(), timestamp, MAX_MESSAGE_COUNT); + } else { + reportSpamData = SignalDatabase.messages().getReportSpamMessageServerData(threadId, timestamp, MAX_MESSAGE_COUNT); + } + + int count = 0; + SignalServiceAccountManager signalServiceAccountManager = ApplicationDependencies.getSignalServiceAccountManager(); for (ReportSpamData data : reportSpamData) { RecipientId recipientId = data.getRecipientId(); @@ -88,7 +108,7 @@ public class ReportSpamJob extends BaseJob { if (reportingTokenBytes != null) { reportingTokenEncoded = Base64.encodeWithPadding(reportingTokenBytes); } - + signalServiceAccountManager.reportSpam(serviceId.get(), data.getServerGuid(), reportingTokenEncoded); count++; } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/GroupInfo.java b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/GroupInfo.java deleted file mode 100644 index 8e41dd5e61..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/GroupInfo.java +++ /dev/null @@ -1,29 +0,0 @@ -package org.thoughtcrime.securesms.messagerequests; - -import androidx.annotation.NonNull; - -public final class GroupInfo { - public static final GroupInfo ZERO = new GroupInfo(0, 0, ""); - - private final int fullMemberCount; - private final int pendingMemberCount; - private final String description; - - public GroupInfo(int fullMemberCount, int pendingMemberCount, @NonNull String description) { - this.fullMemberCount = fullMemberCount; - this.pendingMemberCount = pendingMemberCount; - this.description = description; - } - - public int getFullMemberCount() { - return fullMemberCount; - } - - public int getPendingMemberCount() { - return pendingMemberCount; - } - - public @NonNull String getDescription() { - return description; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/GroupInfo.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/GroupInfo.kt new file mode 100644 index 0000000000..4f3d1c30d5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/GroupInfo.kt @@ -0,0 +1,16 @@ +package org.thoughtcrime.securesms.messagerequests + +/** + * Group info needed to show message request state UX. + */ +class GroupInfo( + val fullMemberCount: Int = 0, + val pendingMemberCount: Int = 0, + val description: String = "", + val hasExistingContacts: Boolean = false +) { + companion object { + @JvmField + val ZERO = GroupInfo() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java index ad7a91c6ef..55c1b6d82d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java @@ -4,7 +4,6 @@ import android.content.Context; import androidx.annotation.NonNull; import androidx.annotation.WorkerThread; -import androidx.core.util.Consumer; import org.signal.core.util.Result; import org.signal.core.util.concurrent.SignalExecutors; @@ -24,12 +23,13 @@ import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason; import org.thoughtcrime.securesms.jobs.MultiDeviceMessageRequestResponseJob; import org.thoughtcrime.securesms.jobs.ReportSpamJob; import org.thoughtcrime.securesms.jobs.SendViewedReceiptJob; +import org.thoughtcrime.securesms.mms.MmsException; +import org.thoughtcrime.securesms.mms.OutgoingMessage; import org.thoughtcrime.securesms.notifications.MarkReadReceiver; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.RecipientUtil; import org.thoughtcrime.securesms.sms.MessageSender; -import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.whispersystems.signalservice.internal.push.exceptions.GroupPatchNotAcceptedException; @@ -37,7 +37,9 @@ import java.io.IOException; import java.util.List; import java.util.Optional; import java.util.concurrent.Executor; +import java.util.concurrent.TimeUnit; +import io.reactivex.rxjava3.core.Completable; import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.schedulers.Schedulers; import kotlin.Unit; @@ -54,28 +56,6 @@ public final class MessageRequestRepository { this.executor = SignalExecutors.BOUNDED; } - public void getGroups(@NonNull RecipientId recipientId, @NonNull Consumer> onGroupsLoaded) { - executor.execute(() -> { - GroupTable groupDatabase = SignalDatabase.groups(); - onGroupsLoaded.accept(groupDatabase.getPushGroupNamesContainingMember(recipientId)); - }); - } - - public void getGroupInfo(@NonNull RecipientId recipientId, @NonNull Consumer onGroupInfoLoaded) { - executor.execute(() -> { - GroupTable groupDatabase = SignalDatabase.groups(); - Optional groupRecord = groupDatabase.getGroup(recipientId); - onGroupInfoLoaded.accept(groupRecord.map(record -> { - if (record.isV2Group()) { - DecryptedGroup decryptedGroup = record.requireV2GroupProperties().getDecryptedGroup(); - return new GroupInfo(decryptedGroup.members.size(), decryptedGroup.pendingMembers.size(), decryptedGroup.description); - } else { - return new GroupInfo(record.getMembers().size(), 0, ""); - } - }).orElse(GroupInfo.ZERO)); - }); - } - @WorkerThread public @NonNull MessageRequestRecipientInfo getRecipientInfo(@NonNull RecipientId recipientId, long threadId) { List sharedGroups = SignalDatabase.groups().getPushGroupNamesContainingMember(recipientId); @@ -83,11 +63,20 @@ public final class MessageRequestRepository { GroupInfo groupInfo = GroupInfo.ZERO; if (groupRecord.isPresent()) { + boolean groupHasExistingContacts = false; if (groupRecord.get().isV2Group()) { + List recipients = Recipient.resolvedList(groupRecord.get().getMembers()); + for (Recipient recipient : recipients) { + if ((recipient.isProfileSharing() || recipient.hasGroupsInCommon()) && !recipient.isSelf()) { + groupHasExistingContacts = true; + break; + } + } + DecryptedGroup decryptedGroup = groupRecord.get().requireV2GroupProperties().getDecryptedGroup(); - groupInfo = new GroupInfo(decryptedGroup.members.size(), decryptedGroup.pendingMembers.size(), decryptedGroup.description); + groupInfo = new GroupInfo(decryptedGroup.members.size(), decryptedGroup.pendingMembers.size(), decryptedGroup.description, groupHasExistingContacts); } else { - groupInfo = new GroupInfo(groupRecord.get().getMembers().size(), 0, ""); + groupInfo = new GroupInfo(groupRecord.get().getMembers().size(), 0, "", false); } } @@ -104,10 +93,11 @@ public final class MessageRequestRepository { @WorkerThread public @NonNull MessageRequestState getMessageRequestState(@NonNull Recipient recipient, long threadId) { if (recipient.isBlocked()) { + boolean reportedAsSpam = reportedAsSpam(threadId); if (recipient.isGroup()) { - return MessageRequestState.BLOCKED_GROUP; + return new MessageRequestState(MessageRequestState.State.BLOCKED_GROUP, reportedAsSpam); } else { - return MessageRequestState.BLOCKED_INDIVIDUAL; + return new MessageRequestState(MessageRequestState.State.INDIVIDUAL_BLOCKED, reportedAsSpam); } } else if (threadId <= 0) { return MessageRequestState.NONE; @@ -115,45 +105,56 @@ public final class MessageRequestRepository { switch (getGroupMemberLevel(recipient.getId())) { case NOT_A_MEMBER: return MessageRequestState.NONE; - case PENDING_MEMBER: - return MessageRequestState.GROUP_V2_INVITE; - default: + case PENDING_MEMBER: { + boolean reportedAsSpam = reportedAsSpam(threadId); + return new MessageRequestState(MessageRequestState.State.GROUP_V2_INVITE, reportedAsSpam); + } + default: { if (RecipientUtil.isMessageRequestAccepted(context, threadId)) { return MessageRequestState.NONE; } else { - return MessageRequestState.GROUP_V2_ADD; + boolean reportedAsSpam = reportedAsSpam(threadId); + return new MessageRequestState(MessageRequestState.State.GROUP_V2_ADD, reportedAsSpam); } + } } } else if (!RecipientUtil.isLegacyProfileSharingAccepted(recipient) && isLegacyThread(recipient)) { if (recipient.isGroup()) { - return MessageRequestState.DEPRECATED_GROUP_V1; + return MessageRequestState.DEPRECATED_V1; } else { - return MessageRequestState.LEGACY_INDIVIDUAL; + return new MessageRequestState(MessageRequestState.State.LEGACY_INDIVIDUAL); } } else if (recipient.isPushV1Group()) { if (RecipientUtil.isMessageRequestAccepted(context, threadId)) { - return MessageRequestState.DEPRECATED_GROUP_V1; + return MessageRequestState.DEPRECATED_V1; } else if (!recipient.isActiveGroup()) { return MessageRequestState.NONE; } else { - return MessageRequestState.DEPRECATED_GROUP_V1; + return MessageRequestState.DEPRECATED_V1; } } else { if (RecipientUtil.isMessageRequestAccepted(context, threadId)) { return MessageRequestState.NONE; } else { - Recipient.HiddenState hiddenState = RecipientUtil.getRecipientHiddenState(threadId); + Recipient.HiddenState hiddenState = RecipientUtil.getRecipientHiddenState(threadId); + boolean reportedAsSpam = reportedAsSpam(threadId); + if (hiddenState == Recipient.HiddenState.NOT_HIDDEN) { - return MessageRequestState.INDIVIDUAL; + return new MessageRequestState(MessageRequestState.State.INDIVIDUAL, reportedAsSpam); } else if (hiddenState == Recipient.HiddenState.HIDDEN) { - return MessageRequestState.NONE_HIDDEN; + return new MessageRequestState(MessageRequestState.State.NONE_HIDDEN, reportedAsSpam); } else { - return MessageRequestState.INDIVIDUAL_HIDDEN; + return new MessageRequestState(MessageRequestState.State.INDIVIDUAL_HIDDEN, reportedAsSpam); } } } } + private boolean reportedAsSpam(long threadId) { + return SignalDatabase.messages().hasReportSpamMessage(threadId) || + SignalDatabase.messages().getOutgoingSecureMessageCount(threadId) > 0; + } + @SuppressWarnings("unchecked") public @NonNull Single> acceptMessageRequest(@NonNull RecipientId recipientId, long threadId) { //noinspection CodeBlock2Expr @@ -172,7 +173,7 @@ public final class MessageRequestRepository { @NonNull Runnable onMessageRequestAccepted, @NonNull GroupChangeErrorCallback error) { - executor.execute(()-> { + executor.execute(() -> { Recipient recipient = Recipient.resolved(recipientId); if (recipient.isPushV2Group()) { try { @@ -182,6 +183,7 @@ public final class MessageRequestRepository { RecipientTable recipientTable = SignalDatabase.recipients(); recipientTable.setProfileSharing(recipientId, true); + insertMessageRequestAccept(recipient, threadId); onMessageRequestAccepted.run(); } catch (GroupChangeException | IOException e) { Log.w(TAG, e); @@ -205,11 +207,25 @@ public final class MessageRequestRepository { ApplicationDependencies.getJobManager().add(MultiDeviceMessageRequestResponseJob.forAccept(recipientId)); } + insertMessageRequestAccept(recipient, threadId); onMessageRequestAccepted.run(); } }); } + private void insertMessageRequestAccept(Recipient recipient, long threadId) { + try { + SignalDatabase.messages().insertMessageOutbox( + OutgoingMessage.messageRequestAcceptMessage(recipient, System.currentTimeMillis(), TimeUnit.SECONDS.toMillis(recipient.getExpiresInSeconds())), + threadId, + false, + null + ); + } catch (MmsException e) { + Log.w(TAG, "Unable to insert message request accept message", e); + } + } + @SuppressWarnings("unchecked") public @NonNull Single> deleteMessageRequest(@NonNull RecipientId recipientId, long threadId) { //noinspection CodeBlock2Expr @@ -295,6 +311,18 @@ public final class MessageRequestRepository { }); } + @SuppressWarnings("unchecked") + public @NonNull Completable reportSpamMessageRequest(@NonNull RecipientId recipientId, long threadId) { + //noinspection CodeBlock2Expr + return Completable.create(emitter -> { + reportSpamMessageRequest( + recipientId, + threadId, + emitter::onComplete + ); + }).subscribeOn(Schedulers.io()); + } + @SuppressWarnings("unchecked") public @NonNull Single> blockAndReportSpamMessageRequest(@NonNull RecipientId recipientId, long threadId) { //noinspection CodeBlock2Expr @@ -315,13 +343,22 @@ public final class MessageRequestRepository { { executor.execute(() -> { Recipient recipient = Recipient.resolved(recipientId); - try{ + try { RecipientUtil.block(context, recipient); + SignalDatabase.messages().insertMessageOutbox( + OutgoingMessage.reportSpamMessage(recipient, System.currentTimeMillis(), TimeUnit.SECONDS.toMillis(recipient.getExpiresInSeconds())), + threadId, + false, + null + ); } catch (GroupChangeException | IOException e) { Log.w(TAG, e); error.onError(GroupChangeFailureReason.fromException(e)); return; + } catch (MmsException e) { + Log.w(TAG, "Unable to insert report spam message", e); } + Recipient.live(recipientId).refresh(); ApplicationDependencies.getJobManager().add(new ReportSpamJob(threadId, System.currentTimeMillis())); @@ -334,6 +371,33 @@ public final class MessageRequestRepository { }); } + private void reportSpamMessageRequest(@NonNull RecipientId recipientId, + long threadId, + @NonNull Runnable onReported) + { + executor.execute(() -> { + try { + Recipient recipient = Recipient.resolved(recipientId); + SignalDatabase.messages().insertMessageOutbox( + OutgoingMessage.reportSpamMessage(recipient, System.currentTimeMillis(), TimeUnit.SECONDS.toMillis(recipient.getExpiresInSeconds())), + threadId, + false, + null + ); + } catch (MmsException e) { + Log.w(TAG, "Unable to insert report spam message", e); + } + + ApplicationDependencies.getJobManager().add(new ReportSpamJob(threadId, System.currentTimeMillis())); + + if (TextSecurePreferences.isMultiDevice(context)) { + ApplicationDependencies.getJobManager().add(MultiDeviceMessageRequestResponseJob.forReportSpam(recipientId)); + } + + onReported.run(); + }); + } + @SuppressWarnings("unchecked") public @NonNull Single> unblockAndAccept(@NonNull RecipientId recipientId) { //noinspection CodeBlock2Expr @@ -361,9 +425,9 @@ public final class MessageRequestRepository { private GroupTable.MemberLevel getGroupMemberLevel(@NonNull RecipientId recipientId) { return SignalDatabase.groups() - .getGroup(recipientId) - .map(g -> g.memberLevel(Recipient.self())) - .orElse(GroupTable.MemberLevel.NOT_A_MEMBER); + .getGroup(recipientId) + .map(g -> g.memberLevel(Recipient.self())) + .orElse(GroupTable.MemberLevel.NOT_A_MEMBER); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestState.java b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestState.java deleted file mode 100644 index 72de0689ec..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestState.java +++ /dev/null @@ -1,36 +0,0 @@ -package org.thoughtcrime.securesms.messagerequests; - -/** - * An enum representing the possible message request states a user can be in. - */ -public enum MessageRequestState { - /** No message request necessary */ - NONE, - - /** No message request necessary as the user was hidden after accepting*/ - NONE_HIDDEN, - - /** A user is blocked */ - BLOCKED_INDIVIDUAL, - - /** A group is blocked */ - BLOCKED_GROUP, - - /** An individual conversation that existed pre-message-requests but doesn't have profile sharing enabled */ - LEGACY_INDIVIDUAL, - - /** A V1 group conversation that is no longer allowed, because we've forced GV2 on. */ - DEPRECATED_GROUP_V1, - - /** An invite response is needed for a V2 group */ - GROUP_V2_INVITE, - - /** A message request is needed for a V2 group */ - GROUP_V2_ADD, - - /** A message request is needed for an individual */ - INDIVIDUAL, - - /** A message request is needed for an individual since they have been hidden */ - INDIVIDUAL_HIDDEN -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestState.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestState.kt new file mode 100644 index 0000000000..ce620d5865 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestState.kt @@ -0,0 +1,56 @@ +package org.thoughtcrime.securesms.messagerequests + +/** + * Data necessary to render message request view. + */ +data class MessageRequestState @JvmOverloads constructor(val state: State = State.NONE, val reportedAsSpam: Boolean = false) { + + companion object { + @JvmField + val NONE = MessageRequestState() + + @JvmField + val DEPRECATED_V1 = MessageRequestState() + } + + val isAccepted: Boolean + get() = state == State.NONE || state == State.NONE_HIDDEN + + val isBlocked: Boolean + get() = state == State.INDIVIDUAL_BLOCKED || state == State.BLOCKED_GROUP + + /** + * An enum representing the possible message request states a user can be in. + */ + enum class State { + /** No message request necessary */ + NONE, + + /** No message request necessary as the user was hidden after accepting */ + NONE_HIDDEN, + + /** A group is blocked */ + BLOCKED_GROUP, + + /** An individual conversation that existed pre-message-requests but doesn't have profile sharing enabled */ + LEGACY_INDIVIDUAL, + + /** A V1 group conversation that is no longer allowed, because we've forced GV2 on. */ + DEPRECATED_GROUP_V1, + + /** An invite response is needed for a V2 group */ + GROUP_V2_INVITE, + + /** A message request is needed for a V2 group */ + GROUP_V2_ADD, + + /** A message request is needed for an individual */ + INDIVIDUAL, + + /** A user is blocked */ + INDIVIDUAL_BLOCKED, + + /** A message request is needed for an individual since they have been hidden */ + INDIVIDUAL_HIDDEN + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestViewModel.java deleted file mode 100644 index 8ca4a6b2cf..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestViewModel.java +++ /dev/null @@ -1,275 +0,0 @@ -package org.thoughtcrime.securesms.messagerequests; - -import android.content.Context; - -import androidx.annotation.MainThread; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.WorkerThread; -import androidx.lifecycle.LiveData; -import androidx.lifecycle.MutableLiveData; -import androidx.lifecycle.ViewModel; -import androidx.lifecycle.ViewModelProvider; - -import org.signal.core.util.concurrent.SignalExecutors; -import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason; -import org.thoughtcrime.securesms.profiles.spoofing.ReviewUtil; -import org.thoughtcrime.securesms.recipients.LiveRecipient; -import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.recipients.RecipientForeverObserver; -import org.thoughtcrime.securesms.recipients.RecipientId; -import org.thoughtcrime.securesms.util.SingleLiveEvent; -import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; -import org.thoughtcrime.securesms.util.livedata.Store; - -import java.util.Collections; -import java.util.List; - -public class MessageRequestViewModel extends ViewModel { - - private final SingleLiveEvent status = new SingleLiveEvent<>(); - private final SingleLiveEvent failures = new SingleLiveEvent<>(); - private final MutableLiveData recipient = new MutableLiveData<>(); - private final MutableLiveData> groups = new MutableLiveData<>(Collections.emptyList()); - private final MutableLiveData groupInfo = new MutableLiveData<>(GroupInfo.ZERO); - private final Store recipientInfoStore = new Store<>(new RecipientInfo(null, null, null, null)); - - private final LiveData messageData; - private final LiveData requestReviewDisplayState; - private final MessageRequestRepository repository; - - private LiveRecipient liveRecipient; - private long threadId; - - private final RecipientForeverObserver recipientObserver = recipient -> { - loadGroupInfo(); - this.recipient.setValue(recipient); - }; - - private MessageRequestViewModel(MessageRequestRepository repository) { - this.repository = repository; - this.messageData = LiveDataUtil.mapAsync(recipient, this::createMessageDataForRecipient); - this.requestReviewDisplayState = LiveDataUtil.mapAsync(messageData, MessageRequestViewModel::transformHolderToReviewDisplayState); - - recipientInfoStore.update(this.recipient, (recipient, state) -> new RecipientInfo(recipient, state.groupInfo, state.sharedGroups, state.messageRequestState)); - recipientInfoStore.update(this.groupInfo, (groupInfo, state) -> new RecipientInfo(state.recipient, groupInfo, state.sharedGroups, state.messageRequestState)); - recipientInfoStore.update(this.groups, (sharedGroups, state) -> new RecipientInfo(state.recipient, state.groupInfo, sharedGroups, state.messageRequestState)); - recipientInfoStore.update(this.messageData, (messageData, state) -> new RecipientInfo(state.recipient, state.groupInfo, state.sharedGroups, messageData.messageState)); - } - - public void setConversationInfo(@NonNull RecipientId recipientId, long threadId) { - if (liveRecipient != null) { - liveRecipient.removeForeverObserver(recipientObserver); - } - - liveRecipient = Recipient.live(recipientId); - this.threadId = threadId; - - loadRecipient(); - loadGroups(); - loadGroupInfo(); - } - - @Override - protected void onCleared() { - if (liveRecipient != null) { - liveRecipient.removeForeverObserver(recipientObserver); - } - } - - public LiveData getRequestReviewDisplayState() { - return requestReviewDisplayState; - } - - public LiveData getRecipient() { - return recipient; - } - - public LiveData getMessageData() { - return messageData; - } - - public LiveData getRecipientInfo() { - return recipientInfoStore.getStateLiveData(); - } - - public LiveData getMessageRequestStatus() { - return status; - } - - public LiveData getFailures() { - return failures; - } - - public boolean shouldShowMessageRequest() { - MessageData data = messageData.getValue(); - return data != null && data.getMessageState() != MessageRequestState.NONE; - } - - @MainThread - public void onAccept() { - status.setValue(Status.ACCEPTING); - repository.acceptMessageRequest(liveRecipient.getId(), - threadId, - () -> status.postValue(Status.ACCEPTED), - this::onGroupChangeError); - } - - @MainThread - public void onDelete() { - status.setValue(Status.DELETING); - repository.deleteMessageRequest(liveRecipient.getId(), - threadId, - () -> status.postValue(Status.DELETED), - this::onGroupChangeError); - } - - @MainThread - public void onBlock() { - status.setValue(Status.BLOCKING); - repository.blockMessageRequest(liveRecipient.getId(), - () -> status.postValue(Status.BLOCKED), - this::onGroupChangeError); - } - - @MainThread - public void onUnblock() { - repository.unblockAndAccept(liveRecipient.getId(), - () -> status.postValue(Status.ACCEPTED)); - } - - @MainThread - public void onBlockAndReportSpam() { - repository.blockAndReportSpamMessageRequest(liveRecipient.getId(), - threadId, - () -> status.postValue(Status.BLOCKED_AND_REPORTED), - this::onGroupChangeError); - } - - private void onGroupChangeError(@NonNull GroupChangeFailureReason error) { - status.postValue(Status.IDLE); - failures.postValue(error); - } - - private void loadRecipient() { - liveRecipient.observeForever(recipientObserver); - SignalExecutors.BOUNDED.execute(() -> { - recipient.postValue(liveRecipient.get()); - }); - } - - private void loadGroups() { - repository.getGroups(liveRecipient.getId(), this.groups::postValue); - } - - private void loadGroupInfo() { - repository.getGroupInfo(liveRecipient.getId(), groupInfo::postValue); - } - - private static RequestReviewDisplayState transformHolderToReviewDisplayState(@NonNull MessageData holder) { - if (holder.getMessageState() == MessageRequestState.INDIVIDUAL) { - return ReviewUtil.isRecipientReviewSuggested(holder.getRecipient().getId()) ? RequestReviewDisplayState.SHOWN - : RequestReviewDisplayState.HIDDEN; - } else { - return RequestReviewDisplayState.NONE; - } - } - - @WorkerThread - private @NonNull MessageData createMessageDataForRecipient(@NonNull Recipient recipient) { - MessageRequestState state = repository.getMessageRequestState(recipient, threadId); - return new MessageData(recipient, state); - } - - public static class RecipientInfo { - @Nullable private final Recipient recipient; - @NonNull private final GroupInfo groupInfo; - @NonNull private final List sharedGroups; - @Nullable private final MessageRequestState messageRequestState; - - public RecipientInfo(@Nullable Recipient recipient, @Nullable GroupInfo groupInfo, @Nullable List sharedGroups, @Nullable MessageRequestState messageRequestState) { - this.recipient = recipient; - this.groupInfo = groupInfo == null ? GroupInfo.ZERO : groupInfo; - this.sharedGroups = sharedGroups == null ? Collections.emptyList() : sharedGroups; - this.messageRequestState = messageRequestState; - } - - @Nullable - public Recipient getRecipient() { - return recipient; - } - - public int getGroupMemberCount() { - return groupInfo.getFullMemberCount(); - } - - public int getGroupPendingMemberCount() { - return groupInfo.getPendingMemberCount(); - } - - public @NonNull String getGroupDescription() { - return groupInfo.getDescription(); - } - - @NonNull - public List getSharedGroups() { - return sharedGroups; - } - - @Nullable - public MessageRequestState getMessageRequestState() { - return messageRequestState; - } - } - - public enum Status { - IDLE, - BLOCKING, - BLOCKED, - BLOCKED_AND_REPORTED, - DELETING, - DELETED, - ACCEPTING, - ACCEPTED - } - - public enum RequestReviewDisplayState { - HIDDEN, - SHOWN, - NONE - } - - public static final class MessageData { - private final Recipient recipient; - private final MessageRequestState messageState; - - public MessageData(@NonNull Recipient recipient, @NonNull MessageRequestState messageState) { - this.recipient = recipient; - this.messageState = messageState; - } - - public @NonNull Recipient getRecipient() { - return recipient; - } - - public @NonNull MessageRequestState getMessageState() { - return messageState; - } - } - - public static class Factory implements ViewModelProvider.Factory { - - private final Context context; - - public Factory(Context context) { - this.context = context; - } - - @Override - public @NonNull T create(@NonNull Class modelClass) { - //noinspection unchecked - return (T) new MessageRequestViewModel(new MessageRequestRepository(context.getApplicationContext())); - } - } - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsBottomView.java b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsBottomView.java deleted file mode 100644 index d21b3075ea..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsBottomView.java +++ /dev/null @@ -1,192 +0,0 @@ -package org.thoughtcrime.securesms.messagerequests; - -import android.content.Context; -import android.content.res.ColorStateList; -import android.util.AttributeSet; -import android.view.View; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.constraintlayout.widget.ConstraintLayout; -import androidx.constraintlayout.widget.Group; -import androidx.core.text.HtmlCompat; - -import com.google.android.material.button.MaterialButton; - -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.util.CommunicationActions; -import org.thoughtcrime.securesms.util.Debouncer; -import org.thoughtcrime.securesms.util.HtmlUtil; -import org.thoughtcrime.securesms.util.views.LearnMoreTextView; - -import java.util.stream.Stream; - -public class MessageRequestsBottomView extends ConstraintLayout { - - private final Debouncer showProgressDebouncer = new Debouncer(250); - - private LearnMoreTextView question; - private MaterialButton accept; - private MaterialButton block; - private MaterialButton delete; - private MaterialButton bigDelete; - private MaterialButton bigUnblock; - private View busyIndicator; - - private Group normalButtons; - private Group blockedButtons; - private @Nullable Group activeGroup; - - public MessageRequestsBottomView(Context context) { - super(context); - onFinishInflate(); - } - - public MessageRequestsBottomView(Context context, AttributeSet attrs) { - super(context, attrs); - } - - public MessageRequestsBottomView(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - } - - @Override - protected void onFinishInflate() { - super.onFinishInflate(); - - inflate(getContext(), R.layout.message_request_bottom_bar, this); - - question = findViewById(R.id.message_request_question); - accept = findViewById(R.id.message_request_accept); - block = findViewById(R.id.message_request_block); - delete = findViewById(R.id.message_request_delete); - bigDelete = findViewById(R.id.message_request_big_delete); - bigUnblock = findViewById(R.id.message_request_big_unblock); - normalButtons = findViewById(R.id.message_request_normal_buttons); - blockedButtons = findViewById(R.id.message_request_blocked_buttons); - busyIndicator = findViewById(R.id.message_request_busy_indicator); - - setWallpaperEnabled(false); - } - - public void setMessageData(@NonNull MessageRequestViewModel.MessageData messageData) { - Recipient recipient = messageData.getRecipient(); - - question.setLearnMoreVisible(false); - question.setOnLinkClickListener(null); - - switch (messageData.getMessageState()) { - case BLOCKED_INDIVIDUAL: - int message = recipient.isReleaseNotes() ? R.string.MessageRequestBottomView_get_updates_and_news_from_s_you_wont_receive_any_updates_until_you_unblock_them - : recipient.isRegistered() ? R.string.MessageRequestBottomView_do_you_want_to_let_s_message_you_wont_receive_any_messages_until_you_unblock_them - : R.string.MessageRequestBottomView_do_you_want_to_let_s_message_you_wont_receive_any_messages_until_you_unblock_them_SMS; - - question.setText(HtmlCompat.fromHtml(getContext().getString(message, - HtmlUtil.bold(recipient.getShortDisplayName(getContext()))), 0)); - setActiveInactiveGroups(blockedButtons, normalButtons); - break; - case BLOCKED_GROUP: - question.setText(R.string.MessageRequestBottomView_unblock_this_group_and_share_your_name_and_photo_with_its_members); - setActiveInactiveGroups(blockedButtons, normalButtons); - break; - case LEGACY_INDIVIDUAL: - question.setText(getContext().getString(R.string.MessageRequestBottomView_continue_your_conversation_with_s_and_share_your_name_and_photo, recipient.getShortDisplayName(getContext()))); - question.setLearnMoreVisible(true); - question.setOnLinkClickListener(v -> CommunicationActions.openBrowserLink(getContext(), getContext().getString(R.string.MessageRequestBottomView_legacy_learn_more_url))); - setActiveInactiveGroups(normalButtons, blockedButtons); - accept.setText(R.string.MessageRequestBottomView_continue); - break; - case DEPRECATED_GROUP_V1: - question.setText(R.string.MessageRequestBottomView_upgrade_this_group_to_activate_new_features); - setActiveInactiveGroups(null, normalButtons, blockedButtons); - break; - case GROUP_V2_INVITE: - question.setText(R.string.MessageRequestBottomView_do_you_want_to_join_this_group_you_wont_see_their_messages); - setActiveInactiveGroups(normalButtons, blockedButtons); - accept.setText(R.string.MessageRequestBottomView_accept); - break; - case GROUP_V2_ADD: - question.setText(R.string.MessageRequestBottomView_join_this_group_they_wont_know_youve_seen_their_messages_until_you_accept); - setActiveInactiveGroups(normalButtons, blockedButtons); - accept.setText(R.string.MessageRequestBottomView_accept); - break; - case INDIVIDUAL: - question.setText(HtmlCompat.fromHtml(getContext().getString(R.string.MessageRequestBottomView_do_you_want_to_let_s_message_you_they_wont_know_youve_seen_their_messages_until_you_accept, - HtmlUtil.bold(recipient.getShortDisplayName(getContext()))), 0)); - setActiveInactiveGroups(normalButtons, blockedButtons); - accept.setText(R.string.MessageRequestBottomView_accept); - break; - case INDIVIDUAL_HIDDEN: - question.setText(HtmlCompat.fromHtml(getContext().getString(R.string.MessageRequestBottomView_do_you_want_to_let_s_message_you_you_removed_them_before, - HtmlUtil.bold(recipient.getShortDisplayName(getContext()))), 0)); - setActiveInactiveGroups(normalButtons, blockedButtons); - accept.setText(R.string.MessageRequestBottomView_accept); - break; - } - } - - private void setActiveInactiveGroups(@Nullable Group activeGroup, @NonNull Group... inActiveGroups) { - int initialVisibility = this.activeGroup != null ? this.activeGroup.getVisibility() : VISIBLE; - - this.activeGroup = activeGroup; - - for (Group inactive : inActiveGroups) { - inactive.setVisibility(GONE); - } - - if (activeGroup != null) { - activeGroup.setVisibility(initialVisibility); - } - } - - public void showBusy() { - showProgressDebouncer.publish(() -> busyIndicator.setVisibility(VISIBLE)); - if (activeGroup != null) { - activeGroup.setVisibility(INVISIBLE); - } - } - - public void hideBusy() { - showProgressDebouncer.clear(); - busyIndicator.setVisibility(GONE); - if (activeGroup != null) { - activeGroup.setVisibility(VISIBLE); - } - } - - public void setWallpaperEnabled(boolean isEnabled) { - MessageRequestBarColorTheme theme = MessageRequestBarColorTheme.resolveTheme(isEnabled); - - Stream.of(delete, bigDelete, block, bigUnblock, accept).forEach(button -> { - button.setBackgroundTintList(ColorStateList.valueOf(theme.getButtonBackgroundColor(getContext()))); - }); - - Stream.of(delete, bigDelete, block).forEach(button -> { - button.setTextColor(theme.getButtonForegroundDenyColor(getContext())); - }); - - Stream.of(accept, bigUnblock).forEach(button -> { - button.setTextColor(theme.getButtonForegroundAcceptColor(getContext())); - }); - - setBackgroundColor(theme.getContainerButtonBackgroundColor(getContext())); - } - - public void setAcceptOnClickListener(OnClickListener acceptOnClickListener) { - accept.setOnClickListener(acceptOnClickListener); - } - - public void setDeleteOnClickListener(OnClickListener deleteOnClickListener) { - delete.setOnClickListener(deleteOnClickListener); - bigDelete.setOnClickListener(deleteOnClickListener); - } - - public void setBlockOnClickListener(OnClickListener blockOnClickListener) { - block.setOnClickListener(blockOnClickListener); - } - - public void setUnblockOnClickListener(OnClickListener unblockOnClickListener) { - bigUnblock.setOnClickListener(unblockOnClickListener); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsBottomView.kt b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsBottomView.kt new file mode 100644 index 0000000000..0dd3d06c25 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsBottomView.kt @@ -0,0 +1,161 @@ +package org.thoughtcrime.securesms.messagerequests + +import android.content.Context +import android.content.res.ColorStateList +import android.util.AttributeSet +import android.view.View +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.text.HtmlCompat +import com.google.android.material.button.MaterialButton +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.messagerequests.MessageRequestBarColorTheme.Companion.resolveTheme +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.util.CommunicationActions +import org.thoughtcrime.securesms.util.Debouncer +import org.thoughtcrime.securesms.util.HtmlUtil +import org.thoughtcrime.securesms.util.views.LearnMoreTextView +import org.thoughtcrime.securesms.util.visible + +/** + * View shown in a conversation during a message request state or related state (e.g., blocked). + */ +class MessageRequestsBottomView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : ConstraintLayout(context, attrs, defStyleAttr) { + private val showProgressDebouncer = Debouncer(250) + + private val question: LearnMoreTextView + private val accept: MaterialButton + private val block: MaterialButton + private val unblock: MaterialButton + private val delete: MaterialButton + private val report: MaterialButton + private val busyIndicator: View + private val buttonBar: View + + init { + inflate(context, R.layout.message_request_bottom_bar, this) + + question = findViewById(R.id.message_request_question) + accept = findViewById(R.id.message_request_accept) + block = findViewById(R.id.message_request_block) + unblock = findViewById(R.id.message_request_unblock) + delete = findViewById(R.id.message_request_delete) + report = findViewById(R.id.message_request_report) + busyIndicator = findViewById(R.id.message_request_busy_indicator) + buttonBar = findViewById(R.id.message_request_button_layout) + + setWallpaperEnabled(false) + } + + fun setMessageRequestData(recipient: Recipient, messageRequestState: MessageRequestState) { + question.setLearnMoreVisible(false) + question.setOnLinkClickListener(null) + + updateButtonVisibility(messageRequestState) + + when (messageRequestState.state) { + MessageRequestState.State.INDIVIDUAL_BLOCKED -> { + val message = if (recipient.isReleaseNotes) R.string.MessageRequestBottomView_get_updates_and_news_from_s_you_wont_receive_any_updates_until_you_unblock_them else if (recipient.isRegistered) R.string.MessageRequestBottomView_do_you_want_to_let_s_message_you_wont_receive_any_messages_until_you_unblock_them else R.string.MessageRequestBottomView_do_you_want_to_let_s_message_you_wont_receive_any_messages_until_you_unblock_them_SMS + question.text = HtmlCompat.fromHtml( + context.getString( + message, + HtmlUtil.bold(recipient.getShortDisplayName(context)) + ), + 0 + ) + } + + MessageRequestState.State.BLOCKED_GROUP -> question.setText(R.string.MessageRequestBottomView_unblock_this_group_and_share_your_name_and_photo_with_its_members) + + MessageRequestState.State.LEGACY_INDIVIDUAL -> { + question.text = context.getString(R.string.MessageRequestBottomView_continue_your_conversation_with_s_and_share_your_name_and_photo, recipient.getShortDisplayName(context)) + question.setLearnMoreVisible(true) + question.setOnLinkClickListener { CommunicationActions.openBrowserLink(context, context.getString(R.string.MessageRequestBottomView_legacy_learn_more_url)) } + accept.setText(R.string.MessageRequestBottomView_continue) + } + + MessageRequestState.State.DEPRECATED_GROUP_V1 -> question.setText(R.string.MessageRequestBottomView_upgrade_this_group_to_activate_new_features) + MessageRequestState.State.GROUP_V2_INVITE -> { + question.setText(R.string.MessageRequestBottomView_do_you_want_to_join_this_group_you_wont_see_their_messages) + accept.setText(R.string.MessageRequestBottomView_accept) + } + + MessageRequestState.State.GROUP_V2_ADD -> { + question.setText(R.string.MessageRequestBottomView_join_this_group_they_wont_know_youve_seen_their_messages_until_you_accept) + accept.setText(R.string.MessageRequestBottomView_accept) + } + + MessageRequestState.State.INDIVIDUAL -> { + question.text = HtmlCompat.fromHtml( + context.getString( + R.string.MessageRequestBottomView_do_you_want_to_let_s_message_you_they_wont_know_youve_seen_their_messages_until_you_accept, + HtmlUtil.bold(recipient.getShortDisplayName(context)) + ), + 0 + ) + accept.setText(R.string.MessageRequestBottomView_accept) + } + + MessageRequestState.State.INDIVIDUAL_HIDDEN -> { + question.text = HtmlCompat.fromHtml( + context.getString( + R.string.MessageRequestBottomView_do_you_want_to_let_s_message_you_you_removed_them_before, + HtmlUtil.bold(recipient.getShortDisplayName(context)) + ), + 0 + ) + accept.setText(R.string.MessageRequestBottomView_accept) + } + + MessageRequestState.State.NONE -> Unit + MessageRequestState.State.NONE_HIDDEN -> Unit + } + } + + private fun updateButtonVisibility(messageState: MessageRequestState) { + accept.visible = !messageState.isBlocked + block.visible = !messageState.isBlocked + unblock.visible = messageState.isBlocked + delete.visible = messageState.reportedAsSpam || messageState.isBlocked + report.visible = !messageState.reportedAsSpam + } + + fun showBusy() { + showProgressDebouncer.publish { busyIndicator.visibility = VISIBLE } + buttonBar.visibility = INVISIBLE + } + + fun hideBusy() { + showProgressDebouncer.clear() + busyIndicator.visibility = GONE + buttonBar.visibility = VISIBLE + } + + fun setWallpaperEnabled(isEnabled: Boolean) { + val theme = resolveTheme(isEnabled) + listOf(delete, block, accept, unblock, report).forEach { it.backgroundTintList = ColorStateList.valueOf(theme.getButtonBackgroundColor(context)) } + listOf(delete, block, report).forEach { it.setTextColor(theme.getButtonForegroundDenyColor(context)) } + listOf(accept, unblock).forEach { it.setTextColor(theme.getButtonForegroundAcceptColor(context)) } + + setBackgroundColor(theme.getContainerButtonBackgroundColor(context)) + } + + fun setAcceptOnClickListener(acceptOnClickListener: OnClickListener?) { + accept.setOnClickListener(acceptOnClickListener) + } + + fun setDeleteOnClickListener(deleteOnClickListener: OnClickListener?) { + delete.setOnClickListener(deleteOnClickListener) + } + + fun setBlockOnClickListener(blockOnClickListener: OnClickListener?) { + block.setOnClickListener(blockOnClickListener) + } + + fun setUnblockOnClickListener(unblockOnClickListener: OnClickListener?) { + unblock.setOnClickListener(unblockOnClickListener) + } + + fun setReportOnClickListener(reportOnClickListener: OnClickListener?) { + report.setOnClickListener(reportOnClickListener) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/DataMessageProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/messages/DataMessageProcessor.kt index 150434a235..5517890d3f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/DataMessageProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/DataMessageProcessor.kt @@ -131,7 +131,18 @@ object DataMessageProcessor { var groupProcessResult: MessageContentProcessor.Gv2PreProcessResult? = null if (groupId != null) { - groupProcessResult = MessageContentProcessor.handleGv2PreProcessing(context, envelope.timestamp!!, content, metadata, groupId, message.groupV2!!, senderRecipient, groupSecretParams) + groupProcessResult = MessageContentProcessor.handleGv2PreProcessing( + context = context, + timestamp = envelope.timestamp!!, + content = content, + metadata = metadata, + groupId = groupId, + groupV2 = message.groupV2!!, + senderRecipient = senderRecipient, + groupSecretParams = groupSecretParams, + serverGuid = envelope.serverGuid + ) + if (groupProcessResult == MessageContentProcessor.Gv2PreProcessResult.IGNORE) { return } diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.kt index 1e3a2be2e7..c3ad8790a4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.kt @@ -228,10 +228,11 @@ open class MessageContentProcessor(private val context: Context) { groupId: GroupId.V2, groupV2: GroupContextV2, senderRecipient: Recipient, - groupSecretParams: GroupSecretParams? = null + groupSecretParams: GroupSecretParams? = null, + serverGuid: String? = null ): Gv2PreProcessResult { val preUpdateGroupRecord = SignalDatabase.groups.getGroup(groupId) - val groupUpdateResult = updateGv2GroupFromServerOrP2PChange(context, timestamp, groupV2, preUpdateGroupRecord, groupSecretParams) + val groupUpdateResult = updateGv2GroupFromServerOrP2PChange(context, timestamp, groupV2, preUpdateGroupRecord, groupSecretParams, serverGuid) if (groupUpdateResult == null) { log(timestamp, "Ignoring GV2 message for group we are not currently in $groupId") return Gv2PreProcessResult.IGNORE @@ -272,13 +273,14 @@ open class MessageContentProcessor(private val context: Context) { timestamp: Long, groupV2: GroupContextV2, localRecord: Optional, - groupSecretParams: GroupSecretParams? = null + groupSecretParams: GroupSecretParams? = null, + serverGuid: String? = null ): GroupsV2StateProcessor.GroupUpdateResult? { return try { val signedGroupChange: ByteArray? = if (groupV2.hasSignedGroupChange) groupV2.signedGroupChange else null val updatedTimestamp = if (signedGroupChange != null) timestamp else timestamp - 1 if (groupV2.revision != null) { - GroupManager.updateGroupFromServer(context, groupV2.groupMasterKey, localRecord, groupSecretParams, groupV2.revision!!, updatedTimestamp, signedGroupChange) + GroupManager.updateGroupFromServer(context, groupV2.groupMasterKey, localRecord, groupSecretParams, groupV2.revision!!, updatedTimestamp, signedGroupChange, serverGuid) } else { warn(timestamp, "Ignore group update message without a revision") null diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/SyncMessageProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/messages/SyncMessageProcessor.kt index 27e940a1aa..d5495d7846 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/SyncMessageProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/SyncMessageProcessor.kt @@ -73,9 +73,6 @@ import org.thoughtcrime.securesms.messages.SignalServiceProtoUtil.toSignalServic import org.thoughtcrime.securesms.messages.SignalServiceProtoUtil.type import org.thoughtcrime.securesms.mms.MmsException import org.thoughtcrime.securesms.mms.OutgoingMessage -import org.thoughtcrime.securesms.mms.OutgoingMessage.Companion.endSessionMessage -import org.thoughtcrime.securesms.mms.OutgoingMessage.Companion.expirationUpdateMessage -import org.thoughtcrime.securesms.mms.OutgoingMessage.Companion.text import org.thoughtcrime.securesms.mms.QuoteModel import org.thoughtcrime.securesms.notifications.MarkReadReceiver import org.thoughtcrime.securesms.payments.MobileCoinPublicAddress @@ -122,6 +119,7 @@ import org.whispersystems.signalservice.internal.push.Verified import java.io.IOException import java.util.Optional import java.util.UUID +import java.util.concurrent.TimeUnit import kotlin.time.Duration object SyncMessageProcessor { @@ -576,7 +574,7 @@ object SyncMessageProcessor { log(envelopeTimestamp, "Synchronize end session message.") val recipient: Recipient = getSyncMessageDestination(sent) - val outgoingEndSessionMessage: OutgoingMessage = endSessionMessage(recipient, sent.timestamp!!) + val outgoingEndSessionMessage: OutgoingMessage = OutgoingMessage.endSessionMessage(recipient, sent.timestamp!!) val threadId: Long = SignalDatabase.threads.getOrCreateThreadIdFor(recipient) if (!recipient.isGroup) { @@ -624,7 +622,7 @@ object SyncMessageProcessor { } val recipient: Recipient = getSyncMessageDestination(sent) - val expirationUpdateMessage: OutgoingMessage = expirationUpdateMessage(recipient, if (sideEffect) sent.timestamp!! - 1 else sent.timestamp!!, sent.message!!.expireTimerDuration.inWholeMilliseconds) + val expirationUpdateMessage: OutgoingMessage = OutgoingMessage.expirationUpdateMessage(recipient, if (sideEffect) sent.timestamp!! - 1 else sent.timestamp!!, sent.message!!.expireTimerDuration.inWholeMilliseconds) val threadId: Long = SignalDatabase.threads.getOrCreateThreadIdFor(recipient) val messageId: Long = SignalDatabase.messages.insertMessageOutbox(expirationUpdateMessage, threadId, false, null) @@ -830,7 +828,7 @@ object SyncMessageProcessor { messageId = SignalDatabase.messages.insertMessageOutbox(outgoingMessage, threadId, false, GroupReceiptTable.STATUS_UNKNOWN, null) updateGroupReceiptStatus(sent, messageId, recipient.requireGroupId()) } else { - val outgoingTextMessage = text(threadRecipient = recipient, body = body, expiresIn = expiresInMillis, sentTimeMillis = sent.timestamp!!, bodyRanges = bodyRanges) + val outgoingTextMessage = OutgoingMessage.text(threadRecipient = recipient, body = body, expiresIn = expiresInMillis, sentTimeMillis = sent.timestamp!!, bodyRanges = bodyRanges) messageId = SignalDatabase.messages.insertMessageOutbox(outgoingTextMessage, threadId, false, null) SignalDatabase.messages.markUnidentified(messageId, sent.isUnidentified(recipient.serviceId.orNull())) } @@ -1058,6 +1056,12 @@ object SyncMessageProcessor { MessageRequestResponse.Type.ACCEPT -> { SignalDatabase.recipients.setProfileSharing(recipient.id, true) SignalDatabase.recipients.setBlocked(recipient.id, false) + SignalDatabase.messages.insertMessageOutbox( + OutgoingMessage.messageRequestAcceptMessage(recipient, System.currentTimeMillis(), TimeUnit.SECONDS.toMillis(recipient.expiresInSeconds.toLong())), + threadId, + false, + null + ) } MessageRequestResponse.Type.DELETE -> { SignalDatabase.recipients.setProfileSharing(recipient.id, false) @@ -1076,6 +1080,24 @@ object SyncMessageProcessor { SignalDatabase.threads.deleteConversation(threadId) } } + MessageRequestResponse.Type.SPAM -> { + SignalDatabase.messages.insertMessageOutbox( + OutgoingMessage.reportSpamMessage(recipient, System.currentTimeMillis(), TimeUnit.SECONDS.toMillis(recipient.expiresInSeconds.toLong())), + threadId, + false, + null + ) + } + MessageRequestResponse.Type.BLOCK_AND_SPAM -> { + SignalDatabase.recipients.setBlocked(recipient.id, true) + SignalDatabase.recipients.setProfileSharing(recipient.id, false) + SignalDatabase.messages.insertMessageOutbox( + OutgoingMessage.reportSpamMessage(recipient, System.currentTimeMillis(), TimeUnit.SECONDS.toMillis(recipient.expiresInSeconds.toLong())), + threadId, + false, + null + ) + } else -> warn("Got an unknown response type! Skipping") } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingMessage.kt index 234ff38a4c..19b7b8a38c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingMessage.kt @@ -94,7 +94,7 @@ class IncomingMessage( } @JvmStatic - fun groupUpdate(from: RecipientId, timestamp: Long, groupId: GroupId, groupContext: DecryptedGroupV2Context): IncomingMessage { + fun groupUpdate(from: RecipientId, timestamp: Long, groupId: GroupId, groupContext: DecryptedGroupV2Context, serverGuid: String?): IncomingMessage { val messageGroupContext = MessageGroupContext(groupContext) return IncomingMessage( @@ -104,6 +104,7 @@ class IncomingMessage( serverTimeMillis = timestamp, groupId = groupId, groupContext = messageGroupContext, + serverGuid = serverGuid, body = messageGroupContext.encodedGroupContext, type = MessageType.GROUP_UPDATE ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMessage.kt index dcce6f0f83..79a7e159a8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMessage.kt @@ -50,7 +50,9 @@ data class OutgoingMessage( val isIdentityVerified: Boolean = false, val isIdentityDefault: Boolean = false, val scheduledDate: Long = -1, - val messageToEdit: Long = 0 + val messageToEdit: Long = 0, + val isReportSpam: Boolean = false, + val isMessageRequestAccept: Boolean = false ) { val isV2Group: Boolean = messageGroupContext != null && GroupV2UpdateMessageUtil.isGroupV2(messageGroupContext) @@ -401,6 +403,30 @@ data class OutgoingMessage( ) } + @JvmStatic + fun reportSpamMessage(threadRecipient: Recipient, sentTimeMillis: Long, expiresIn: Long): OutgoingMessage { + return OutgoingMessage( + threadRecipient = threadRecipient, + sentTimeMillis = sentTimeMillis, + expiresIn = expiresIn, + isReportSpam = true, + isUrgent = false, + isSecure = true + ) + } + + @JvmStatic + fun messageRequestAcceptMessage(threadRecipient: Recipient, sentTimeMillis: Long, expiresIn: Long): OutgoingMessage { + return OutgoingMessage( + threadRecipient = threadRecipient, + sentTimeMillis = sentTimeMillis, + expiresIn = expiresIn, + isMessageRequestAccept = true, + isUrgent = false, + isSecure = true + ) + } + @JvmStatic fun buildMessage(slideDeck: SlideDeck, message: String): String { return if (message.isNotEmpty() && slideDeck.body.isNotEmpty()) { diff --git a/app/src/main/res/drawable-xxhdpi/safety_tip1.png b/app/src/main/res/drawable-xxhdpi/safety_tip1.png new file mode 100644 index 0000000000..adfaf2d763 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/safety_tip1.png differ diff --git a/app/src/main/res/drawable-xxhdpi/safety_tip2.png b/app/src/main/res/drawable-xxhdpi/safety_tip2.png new file mode 100644 index 0000000000..fc480f1b3e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/safety_tip2.png differ diff --git a/app/src/main/res/drawable-xxhdpi/safety_tip3.png b/app/src/main/res/drawable-xxhdpi/safety_tip3.png new file mode 100644 index 0000000000..edbc3797cb Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/safety_tip3.png differ diff --git a/app/src/main/res/drawable-xxhdpi/safety_tip4.png b/app/src/main/res/drawable-xxhdpi/safety_tip4.png new file mode 100644 index 0000000000..2a0e16c1be Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/safety_tip4.png differ diff --git a/app/src/main/res/drawable/symbol_block_24.xml b/app/src/main/res/drawable/symbol_block_24.xml new file mode 100644 index 0000000000..17dc5c451f --- /dev/null +++ b/app/src/main/res/drawable/symbol_block_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/symbol_leave_24.xml b/app/src/main/res/drawable/symbol_leave_24.xml new file mode 100644 index 0000000000..568d033669 --- /dev/null +++ b/app/src/main/res/drawable/symbol_leave_24.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/symbol_spam_16.xml b/app/src/main/res/drawable/symbol_spam_16.xml new file mode 100644 index 0000000000..9e382372ca --- /dev/null +++ b/app/src/main/res/drawable/symbol_spam_16.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/symbol_spam_24.xml b/app/src/main/res/drawable/symbol_spam_24.xml new file mode 100644 index 0000000000..2dab928bcc --- /dev/null +++ b/app/src/main/res/drawable/symbol_spam_24.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/symbol_thread_16.xml b/app/src/main/res/drawable/symbol_thread_16.xml new file mode 100644 index 0000000000..6fc31c6cdb --- /dev/null +++ b/app/src/main/res/drawable/symbol_thread_16.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/layout/conversation_header_view.xml b/app/src/main/res/layout/conversation_header_view.xml index de150c6d76..d86b25b368 100644 --- a/app/src/main/res/layout/conversation_header_view.xml +++ b/app/src/main/res/layout/conversation_header_view.xml @@ -142,11 +142,27 @@ android:textAppearance="@style/Signal.Text.BodyMedium" android:visibility="gone" app:layout_constrainedWidth="true" - app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintBottom_toTopOf="@+id/message_request_button" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/message_request_subtitle" - tools:text="Member of NYC Rock Climbers, Dinner Party and Friends" /> + app:layout_goneMarginTop="0dp" + tools:text="Member of NYC Rock Climbers, Dinner Party and Friends" + tools:visibility="visible" /> + + diff --git a/app/src/main/res/layout/message_request_bottom_bar.xml b/app/src/main/res/layout/message_request_bottom_bar.xml index 5b7eb20eba..a82606ce09 100644 --- a/app/src/main/res/layout/message_request_bottom_bar.xml +++ b/app/src/main/res/layout/message_request_bottom_bar.xml @@ -1,118 +1,87 @@ + tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout" + tools:viewBindingIgnore="true"> - - - - - + app:layout_constraintStart_toStartOf="parent"> - + - + - + - + - + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-night/dark_colors.xml b/app/src/main/res/values-night/dark_colors.xml index 520f19baa2..9e8e2b725f 100644 --- a/app/src/main/res/values-night/dark_colors.xml +++ b/app/src/main/res/values-night/dark_colors.xml @@ -191,4 +191,6 @@ @color/transparent_white_20 + @color/signal_dark_colorSecondaryContainer + @color/signal_dark_colorSecondary diff --git a/app/src/main/res/values/light_colors.xml b/app/src/main/res/values/light_colors.xml index c44445049f..1d9c5e8614 100644 --- a/app/src/main/res/values/light_colors.xml +++ b/app/src/main/res/values/light_colors.xml @@ -203,4 +203,6 @@ @color/signal_colorError @color/signal_colorNeutralInverse + @color/core_white + #99F2F5F9 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7115549935..b3f59c619a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -179,7 +179,18 @@ Unblock %1$s? Block Block and Leave - Report spam and block + + Report and block + + Report spam? + + Report spam + + Signal will be notified that this person may be sending spam. Signal can’t see the content of any chats. + + Signal will be notified that %1$s, who invited you to this group, may be sending spam. Signal can’t see the content of any chats. + + Signal will be notified that the person who invited you to this group may be sending spam. Signal can’t see the content of any chats. Today @@ -510,6 +521,55 @@ Open Signal No longer verified + + Safety tips + + Report spam + + Block + + Accept + + Delete chat + + Unblock + + Reported spam + + Signal has been be notified that this person may be sending spam. Signal can’t see the content of any chats. + + Reported as spam + + Reported as spam and blocked + + You accepted a message request from %1$s. If this was a mistake, you can choose an action below. + + + Safety Tips + + Be careful when accepting message requests from people you don’t know. Watch out for: + + Review this request carefully. None of your contacts or people you chat with are in this group. Here are a few things to watch out for: + + Previous tip + + Next tip + + Crypto or money scams + + If someone you don’t know messages about cryptocurrency (like Bitcoin) or an financial opportunity, be careful—it’s likely spam. + + Vague or irrelevant messages + + Spammers often start with a simple message like “Hi” to draw you in. If you respond they may engage you further. + + Messages with links + + Be careful of messages from people you don’t know that have links to websites. Never visit links from people you don’t trust. + + Fake businesses and institutions + + Be careful of businesses or government agencies contacting you. Messages involving tax agencies, couriers, and more can be spam. Clear filter @@ -1536,6 +1596,10 @@ You can no longer send SMS messages in Signal. Invite %1$s to Signal to keep the conversation here. Payment: %1$s + + Reported as spam + + You accepted the message request Accept @@ -1582,6 +1646,8 @@ %d additional group %d additional groups + + Report… Passphrases don\'t match! @@ -2648,6 +2714,8 @@ Activate payments You have removed this person, messaging them again will add them back to your list. + + Options Play … Pause diff --git a/app/src/spinner/java/org/thoughtcrime/securesms/database/MessageBitmaskColumnTransformer.kt b/app/src/spinner/java/org/thoughtcrime/securesms/database/MessageBitmaskColumnTransformer.kt index 50678e6eec..7fab1d75c3 100644 --- a/app/src/spinner/java/org/thoughtcrime/securesms/database/MessageBitmaskColumnTransformer.kt +++ b/app/src/spinner/java/org/thoughtcrime/securesms/database/MessageBitmaskColumnTransformer.kt @@ -52,9 +52,11 @@ import org.thoughtcrime.securesms.database.MessageTypes.SECURE_MESSAGE_BIT import org.thoughtcrime.securesms.database.MessageTypes.SMS_EXPORT_TYPE import org.thoughtcrime.securesms.database.MessageTypes.SPECIAL_TYPES_MASK import org.thoughtcrime.securesms.database.MessageTypes.SPECIAL_TYPE_GIFT_BADGE +import org.thoughtcrime.securesms.database.MessageTypes.SPECIAL_TYPE_MESSAGE_REQUEST_ACCEPTED import org.thoughtcrime.securesms.database.MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATED import org.thoughtcrime.securesms.database.MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATE_REQUEST import org.thoughtcrime.securesms.database.MessageTypes.SPECIAL_TYPE_PAYMENTS_NOTIFICATION +import org.thoughtcrime.securesms.database.MessageTypes.SPECIAL_TYPE_REPORTED_SPAM import org.thoughtcrime.securesms.database.MessageTypes.SPECIAL_TYPE_STORY_REACTION import org.thoughtcrime.securesms.database.MessageTypes.THREAD_MERGE_TYPE import org.thoughtcrime.securesms.database.MessageTypes.UNSUPPORTED_MESSAGE_TYPE @@ -131,6 +133,8 @@ object MessageBitmaskColumnTransformer : ColumnTransformer { isPaymentsNotificaiton:${type and SPECIAL_TYPES_MASK == SPECIAL_TYPE_PAYMENTS_NOTIFICATION} isRequestToActivatePayments:${type and SPECIAL_TYPES_MASK == SPECIAL_TYPE_PAYMENTS_ACTIVATE_REQUEST} isPaymentsActivated:${type and SPECIAL_TYPES_MASK == SPECIAL_TYPE_PAYMENTS_ACTIVATED} + isReportSpam:${type and SPECIAL_TYPES_MASK == SPECIAL_TYPE_REPORTED_SPAM} + isMessageRequestAccepted:${type and SPECIAL_TYPES_MASK == SPECIAL_TYPE_MESSAGE_REQUEST_ACCEPTED} """.trimIndent() return "$type

" + describe.replace(Regex("is[A-Z][A-Za-z0-9]*:false\n?"), "").replace("\n", "
") 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 c14178cbe7..686a26f6e4 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 @@ -469,8 +469,8 @@ class GroupsV2StateProcessorTest { profileAndMessageHelper.masterKey = masterKey val updateMessageContextArgs = mutableListOf() - every { profileAndMessageHelper.insertUpdateMessages(any(), any(), any()) } answers { callOriginal() } - every { profileAndMessageHelper.storeMessage(capture(updateMessageContextArgs), any()) } returns Unit + every { profileAndMessageHelper.insertUpdateMessages(any(), any(), any(), any()) } answers { callOriginal() } + every { profileAndMessageHelper.storeMessage(capture(updateMessageContextArgs), any(), any()) } returns Unit given { localState( @@ -520,8 +520,8 @@ class GroupsV2StateProcessorTest { profileAndMessageHelper.masterKey = masterKey val updateMessageContextArgs = mutableListOf() - every { profileAndMessageHelper.insertUpdateMessages(any(), any(), any()) } answers { callOriginal() } - every { profileAndMessageHelper.storeMessage(capture(updateMessageContextArgs), any()) } returns Unit + every { profileAndMessageHelper.insertUpdateMessages(any(), any(), any(), any()) } answers { callOriginal() } + every { profileAndMessageHelper.storeMessage(capture(updateMessageContextArgs), any(), any()) } returns Unit given { localState( diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java index e5a46c2b30..ee987aec29 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java @@ -1607,6 +1607,12 @@ public class SignalServiceMessageSender { case BLOCK_AND_DELETE: responseMessage.type(SyncMessage.MessageRequestResponse.Type.BLOCK_AND_DELETE); break; + case SPAM: + responseMessage.type(SyncMessage.MessageRequestResponse.Type.SPAM); + break; + case BLOCK_AND_SPAM: + responseMessage.type(SyncMessage.MessageRequestResponse.Type.BLOCK_AND_SPAM); + break; default: Log.w(TAG, "Unknown type!"); responseMessage.type(SyncMessage.MessageRequestResponse.Type.UNKNOWN); diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/MessageRequestResponseMessage.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/MessageRequestResponseMessage.java index c98666afdf..1bcddfe659 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/MessageRequestResponseMessage.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/MessageRequestResponseMessage.java @@ -43,6 +43,6 @@ public class MessageRequestResponseMessage { } public enum Type { - UNKNOWN, ACCEPT, DELETE, BLOCK, BLOCK_AND_DELETE, UNBLOCK_AND_ACCEPT + UNKNOWN, ACCEPT, DELETE, BLOCK, BLOCK_AND_DELETE, UNBLOCK_AND_ACCEPT, SPAM, BLOCK_AND_SPAM } } diff --git a/libsignal-service/src/main/protowire/SignalService.proto b/libsignal-service/src/main/protowire/SignalService.proto index c152a79347..2de3e6ac75 100644 --- a/libsignal-service/src/main/protowire/SignalService.proto +++ b/libsignal-service/src/main/protowire/SignalService.proto @@ -549,6 +549,8 @@ message SyncMessage { DELETE = 2; BLOCK = 3; BLOCK_AND_DELETE = 4; + SPAM = 5; + BLOCK_AND_SPAM = 6; } reserved /*threadE164*/ 1;