From aa76cefb1c5c6eaaeadcae362de072ad4f602177 Mon Sep 17 00:00:00 2001 From: Cody Henthorne Date: Fri, 9 Feb 2024 15:25:31 -0500 Subject: [PATCH] Update spam UX and reporting flows. --- .../v2/items/V2ConversationItemShapeTest.kt | 6 + ...est_collapseJoinRequestEventsIfPossible.kt | 3 +- .../test/InternalConversationTestFragment.kt | 12 + .../securesms/BindableConversationItem.java | 3 + .../securesms/BlockUnblockDialog.java | 59 +++- .../ConversationSettingsFragment.kt | 55 +++- .../ConversationSettingsViewModel.kt | 49 +++- .../conversation/ConversationHeaderView.java | 10 + .../conversation/ConversationOptionsMenu.kt | 42 ++- .../conversation/ConversationUpdateItem.java | 16 + .../ScheduledMessagesBottomSheet.kt | 3 + .../quotes/MessageQuotesBottomSheet.kt | 4 + .../ui/edit/EditMessageHistoryDialog.kt | 3 + .../conversation/v2/ConversationAdapterV2.kt | 17 +- .../conversation/v2/ConversationDialogs.kt | 21 +- .../conversation/v2/ConversationFragment.kt | 247 +++++++++++----- .../conversation/v2/ConversationRepository.kt | 2 +- .../conversation/v2/ConversationViewModel.kt | 8 +- .../conversation/v2/DisabledInputView.kt | 9 +- .../v2/MessageRequestViewModel.kt | 9 + .../v2/SafetyTipsBottomSheetDialog.kt | 277 ++++++++++++++++++ .../v2/data/ConversationDataSource.kt | 6 - .../securesms/database/GroupTable.kt | 24 ++ .../securesms/database/MessageTable.kt | 78 ++++- .../securesms/database/MessageTypes.java | 10 + .../database/model/DisplayRecord.java | 8 + .../database/model/InMemoryMessageRecord.java | 39 --- .../database/model/MessageRecord.java | 6 +- .../securesms/groups/GroupManager.java | 5 +- .../securesms/groups/GroupManagerV2.java | 9 +- .../v2/processing/GroupsV2StateProcessor.java | 33 ++- .../MultiDeviceMessageRequestResponseJob.java | 12 +- .../securesms/jobs/ReportSpamJob.java | 28 +- .../securesms/messagerequests/GroupInfo.java | 29 -- .../securesms/messagerequests/GroupInfo.kt | 16 + .../MessageRequestRepository.java | 154 +++++++--- .../messagerequests/MessageRequestState.java | 36 --- .../messagerequests/MessageRequestState.kt | 56 ++++ .../MessageRequestViewModel.java | 275 ----------------- .../MessageRequestsBottomView.java | 192 ------------ .../MessageRequestsBottomView.kt | 161 ++++++++++ .../messages/DataMessageProcessor.kt | 13 +- .../messages/MessageContentProcessor.kt | 10 +- .../messages/SyncMessageProcessor.kt | 34 ++- .../securesms/mms/IncomingMessage.kt | 3 +- .../securesms/mms/OutgoingMessage.kt | 28 +- .../main/res/drawable-xxhdpi/safety_tip1.png | Bin 0 -> 25316 bytes .../main/res/drawable-xxhdpi/safety_tip2.png | Bin 0 -> 11872 bytes .../main/res/drawable-xxhdpi/safety_tip3.png | Bin 0 -> 14408 bytes .../main/res/drawable-xxhdpi/safety_tip4.png | Bin 0 -> 17933 bytes app/src/main/res/drawable/symbol_block_24.xml | 9 + app/src/main/res/drawable/symbol_leave_24.xml | 12 + app/src/main/res/drawable/symbol_spam_16.xml | 15 + app/src/main/res/drawable/symbol_spam_24.xml | 15 + .../main/res/drawable/symbol_thread_16.xml | 12 + .../res/layout/conversation_header_view.xml | 20 +- .../res/layout/message_request_bottom_bar.xml | 149 ++++------ .../res/menu/conversation_message_request.xml | 24 ++ app/src/main/res/values-night/dark_colors.xml | 2 + app/src/main/res/values/light_colors.xml | 2 + app/src/main/res/values/strings.xml | 70 ++++- .../MessageBitmaskColumnTransformer.kt | 4 + .../processing/GroupsV2StateProcessorTest.kt | 8 +- .../api/SignalServiceMessageSender.java | 6 + .../MessageRequestResponseMessage.java | 2 +- .../src/main/protowire/SignalService.proto | 2 + 66 files changed, 1578 insertions(+), 894 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/SafetyTipsBottomSheetDialog.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/messagerequests/GroupInfo.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/messagerequests/GroupInfo.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestState.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestState.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestViewModel.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsBottomView.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsBottomView.kt create mode 100644 app/src/main/res/drawable-xxhdpi/safety_tip1.png create mode 100644 app/src/main/res/drawable-xxhdpi/safety_tip2.png create mode 100644 app/src/main/res/drawable-xxhdpi/safety_tip3.png create mode 100644 app/src/main/res/drawable-xxhdpi/safety_tip4.png create mode 100644 app/src/main/res/drawable/symbol_block_24.xml create mode 100644 app/src/main/res/drawable/symbol_leave_24.xml create mode 100644 app/src/main/res/drawable/symbol_spam_16.xml create mode 100644 app/src/main/res/drawable/symbol_spam_24.xml create mode 100644 app/src/main/res/drawable/symbol_thread_16.xml create mode 100644 app/src/main/res/menu/conversation_message_request.xml 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 0000000000000000000000000000000000000000..adfaf2d763fc51c79f6a9f0302029c3a4a5b33e0 GIT binary patch literal 25316 zcmc$E^-~sE5)U_yK5;<-}v3(u0@I!IP`FLmv28mJb%NJ znM|^qolJJJugLDkYH288p_8M-!NFmvD9h`>!F?EqgM;@*Mfe}b^8DyugXkcmE&~VG zn2hmah4hbx_tH_4g=?6iJp4zXc_>Zjb|+P|9R9#t?=^qFu}E+g^|4iV{!DX4in@XP;dw}a`Xk$u z1I9}O`j6_0*Df@5eM5g-#GZlfy2fA6323e0_RUc*Lvw3x)3B;kU-0Pk&gLpK6+O&? zAI|;IkYJv()>IN@0{$cy48IWcc*TdTvSKr^v zuIUzKf9JoWS0-51;i4sXZZ+kHtWQ?Wo6W1)OcOz;i}=>7R^;uG5! zOyr(Q+nKKTlgrZ@f6>j4M1znzkHVp2O|{mFRM_0y&h3+C_Owp$PrKlx&R?ZR+}ypc z*ik@TNQ~6m3dtv%yp3wV^@;xS)qC#Gw(k$oThnQ;o1C$DSVtbNo9*csl_QX^!$%@8tdzpLT?6FRLz0?dL?BnThpBI=7T??~7>^nCR}kTgmzRtK*asKHoGBvI)AT#pLLR~w$sca}CY6l)gS6qg~k>C9VEq?v9 z3LDw%S#@bP&kswmhJO=UTXd%7EeZ;KpH3t`CrfKZp^LyNZ_Z2(U8D+>E=l_1BGU?Y z%A0H2#3yGvLyRJgVcrC{@?X0_`@t~t2Yy3)3J8+)1h+2(Y?jI~zAqsabH$pwoDm@c zxmn@M*^mN>B*F}~A(?OBYnUhWzcN6m;3u+0?9xw6uxeE99P^IQJj|6J+!$DruPWhK zejdG;-^gp5{v9VMYGu=`y|y#hpbql=CWc;H%Oiy-27Y@63{WJ3>t+*!3O8BK8IXSb zdnENH0+z?}Nh5m+b3~L6=Gc60!V;(S03d6!U<~vf0jqz_fe4Wxpb5*udWHp}9?%}6 z*0$*+;7BRKv|y)#TW9ug6^?A3J$W#ZftD^4(TJLM`Q7*k*P|4ZT1Sh6PRw^AiBrI;vW%SMPgj`7 zn7N57eTEl|g@*kTP-{Q)`cJCXFM(XS6b{AWMAzW0MB0=NxbPtY3CVdqGgz~igQ=57BUB47E*mX z>B(;mJWVBOj6^%vUqp@fTih8k@vU|ooXrmOiB#ifrKLkd3L6_i-Ly6lO2*t1x1hx7 z-M(_}dOREBxmitI@}Np_h{;=4V~<4|Y~%_0b>r`>vB14)mgyx~*704}M1^Hf2Tw=P ze7c71K!FRLD-^p7y5sL5n0iCnzBvK)FmPx{)N6Qm%U!_G=D(FG4)FEyxgClpsV%tj z1bi~f!85^rXhq?w0q^&X5LL8}H>EjiYQ+6}x8vk88Na{i*`H zEypv77>Sg}o=GqabS9dQ!h?Rs7GOItw@37yp0+X$udLkJy0N+R79`sZWKeWYu1WqK zKir@%zur1ofVM`IbC?AT%4#e=lS?|2gxm5Q>Ru8l$H~fQuj`6Xqegyy=0cMcN0|8j znYQS2=JD7^(VtMAu;GBiSLn;S{~48Qs$Qo~9M^lZ&a~35x!p0x5RtLjMNu9_$^zi2 zOBP)5*-E6<<&Ou|?es>ERCYa5gtICR8gE3ff6IRrhTtE?z=75kg){sKiN7Yw2Ome@ zM9}TQ;yKlnP)T8vn1fH&Lh^1+dglT1Ox=~@6Bz-O$hmmqe7l0Vf{NlPlYyw5} z9qjB8a)0Q`iu(a>I5 z%jU&613_;1LcNmdj`EUuJ8$MO`NYdE#%yQ4aC=WLz#yaRw^M_WWHFlURlr=0r`j$q zUxUuM`mpu)-6+CbAeWwj*L1tG^rwsrh(l#Q;3vQs?Gou4VB+C;eYQ)Sy1aOuf4Ok= zP(J%;8~(9~4jiEha~v&faPWyKNcw|ZDo2KN4fTSxWhb>QCf~3N-*Ngej(9yTVz^1I zi!Lm2pLmDX>e>YpIBb_D1DN=B0#oN{h~m}*nbNDz)Hk{U38jKFsIfopCOWHCyhlY3N|b*|9NyBk%+yDM0z zz%@~5Ij((X&cjEbRfSpig{MXu3|v1hxpfo$%2_S0&eV+53}DpaS+{Lx7TwpZ)K8}e z_tgIfo=mJg)LB+9v@6;L!VHYuGztp^d{&$dL_|nB(p0@3p|<*VW{EM_!PaIdQg3vr zj)y`7_nyjG@npXSLS)jt z*XP&tTtKZWbzqILos{~N;V(ZSL0^kzt?)sy=>@*bx!J0oz_t!mu&00@r`kUd=t-I_ z31vP9fX1kUX-Bv+f4>PiMx!g}ob}}l9C|0JV$-V}9 z2F^?XqeqO-HZS0Iu|aVJd_RCRl0PozaERg;`cMWqnRRo4N}W^HtL*LL#FVI{Av^hn z@m->UZH>*%*9Xhg*f6$95POGc?ZSFzwX4*do{iz1eEIHxgN0-nluJ`%>Z@DMaCaVs zttmsL>%PMNw1#7!)QO9@nEz{LUVdT$S58v(@e>3X!c{y@Et>V%vqcxzbI5DB`?eg| zD9pnvc>)vxjki+?Bma~n_Ymo{RkUYnxQ+5{)Z+N2Yjj}!En%YG@<4^IFGI!T&L{a`OQUr_xiOStGBxe6=hCuzFZ~Zo2 z9`B3S9(T}mBk~J0rk{(YLf-68{VI*W))+XGvzWWdB2>RhJhWw^9># zA|NySj|<&LLI$RwJUM)9hM!^6L1;Hdhh%f9zdpKl{bFyO-tWdd#ZRwn9^_9cwQ(-q zvmwscCxmUu67qmj>JswN<4<_!@@mH?1k%tebsB7t{lUm&cT)Y$cxXC)FMU^jG!pJl zAsG@5S-#|460+W8>$gMHjqxHsNb{%!zfp5ip;B>mEvuZ!!zG}2c_n?* zkE8P(GtwapIq|=|VxxiOg1w>f8dVCx-Wt?f@`^PFJ5p=xd9_W!Fg@=J;0>>{X zWJg0*(j7baQO7s$@)!&+{^}Ek|CKCq_rt-T|MYa(`)Aa&lkeYDh`yG>L87ZBsI8^e zoim+g63N9hY_X#T|L9=L9lm3OFWuNTRRHl^Es1|yyeMo z6fXrfBiG$J1F>EjJSLJI+(1@4)*U9XO=N<6<@00d*NPP*M^9845nQ>o7fa3LU$Q2< z8gr6lsGJ5%^c>ip!bfR@NNF5XfZ1<+df=9P!{dIpA-oK(be2JW2%C!3#J^_5^C1*nL#LGI#GE0O4uu$GQ>#ZUq1 z!b-4*2jPYk@to-Z*~%E+)l#{%OOso5Frz5^E-W3!jVqLM9#(v~xzhaJ06lbEaKJ_m zJaK7gylw;2uoP3CGaK{Szgn(uPe{teAFKzAzz4dr*D{1$)C=#%0K=CiNKnag%A%Vv zZNG|i*|L8_!ba3L%S9bf(l|H6T-59|bSz7NAgv$YQV6d)#iI<`29~XicDjBbngnj??P&i4L9G4qj~3EA9k6OWFCEHXI&xMZFf0&w%oOqQ?%OQ=_kZ>- zpR?cPc3-Ap=0$k){j*Gx90tBw->IO-tOGGZpRM>#y<2NKW>BvuQp~`>Hd<8E;cTW@ zVas?CoGtLAqy+=Q!60^20Zae!8QMasQNibDP}EqMN}0?xe^l;@9TtV!K=;?&vaj2c zrtA83{3C+mQ-}?u;k2*Ht!+G}U{!VID<*BU{BoFiYtSdxvI`5dtDV`x$5sOJHE`O_Acnr(X!1IT&pT?O{^;F z@m~i$ENL^zAai|K_=1GJ%caYIj!OmCt_v8<81#=H!%Mxtx!&CIAxzC+r5{4F?m5y| z=~Kqnp7!Rk+D&D!^R)UY0KIGJ@erS&s#W`)pi=8mfcBQ|BY2sN8 z`qQh*irbv1o>@-RxY_3M@=+SCrt8G0(PJVyZ#{r8V|6&VYH>eryV^p$!lR zwRwvw|D-1Yf}sXtV}Fs2^wM;V8R*MI#D6z_$4x63{QykN;V1|u2;~%?#P}ROZzZS%LtE%tFMY#6! z_UAB`h-!~OZiY;P^Z9d|3g#|n{gcb~n^%kw$P$BLr*p?++ED!IFLwZ|w&KEV_rk~J z`$O9#4!49*&XIQFlRG7`fY9ek>Wy-N42|X(&dnrSC9LxyFZn$(on^#G}}zj z?0>_Oy_B5+_7wDxTIZ^cc5H}}rgnRA3MOR6tZEReqP6Yqvay+@5izaPMDhGoLSki2 z!lWk|vOwbuX2>;iXCV-ZBnRAg-3xr!c_#l)|7^S_nPX~-o1ee1Zd|`TuBL82JnE7;riq$s2M!m~%uK<6vtmGcEE(*&5xa8ofQ{GaP9E3=M*^wmXZ< zF$kw;rX-z<6XIElfTCgeaa;B;6ozV|aQ-nOnOuqI z=3p@nxxS2v6+dOGK1W%W9OP@6onw>wmEbCByJ_>c-E+T*-LGu`$snC+4v8R03!?3b zSxh|x!M383K;Biy$}w}*^Y1VIB1jNCOKhL>2se%sr=yy|Fa>_!ahFpc8?x^Nm9d+j zP$M4yvy^@`aNDS~@A`(F)sOC+Cyo(`z*H@?Lz>!|7zG>QAT2*7o-H zWV%84z#vZM|0W;zg&I;O(LL1q&{%YXN}Rs~_MDdOTEoxaetX`fI$okPH4^Pdzut{# zFb~af&il-Mz*%gz?&Gz)2M$c%b`HgNRk?>Knk!u{v{yRYiE9+|C$%jtEjiD6T=8zp zW)D`hh}s6+HU-0dVD#TEYvEJ&vtoxK186yI=6tcL7m|IaVlHaROp{oUmYlHdV>w$wdmh$IwG?Ao)0-1O0d!!5ZHf`WL}x9AmxdJf7%ZAm0`|2UsgSPFN9 z_y+gDdV+aGc`+F}E*|knW|6X-Xt1MUbq35<{(>u>+yR1$QDijBM`ex<=jsKDK4F}> z14<2_WRRB2UVad zn2G70HI!=sUW)R{+)p*uCbEnYrGG!q9d$dJF}~WA-klUeBxV&{HNTiT8|!ksm23G< zl-`&idWzf)buAN}`4??W?o2Z=t>^kLkX#)m-(3goW4-zs*x$35&Qi5&ag)d0NKqRsi;uu;_ z#y|b)Z@7hq^u z>2;_dz`Hzx|HljE3v(B?o-&1Hz^j#&{o8z1U*U>e`trV}P#T(w0QmVJDb*1JwzMkdUZEkdE`h2j5 zwQNWO;6X-jt@Pjbbcu&CrK%7Zu6h(7k2(gjrwCb@5)^KFY-R^$5pG_Kz1=3sS~pgu zra1QaG5ATm`PQ~YouIs-A=sV&x2;9qm?O0L#9la^BDZa6uu!B`sFpDjpdn;jd#eHi zw!lw~|JdqY3v!I3{f4D-Hg66YQ#Yw!a;I~cx}hwT?%|j%dX^mX`hxZEb2KEh97i2! zf=HBrQ6On%Y8oKgTkSaP1p45sHDKi<4WCFN4mWFn#qqLo9LX%LWkHE z6NeH!NFX}+KvXqmGy45NhLHOk=45=$pc9}-*RgLO!|SnQclWgL-upBpbU(PNu#)^2 zg|($wcQx5PMtUFUZ20WNbUgDJ*>8W~Ls&aR@!Z^E0lW!}8$-@k`)M7*qvUU~zV5L3 zF{n>UqjXHv@5^h!9W6Cy4x_wKZkbx5it8ZG7bBwRN>P5Z1E*#$1a;&F)=gWisRqx8U~Ey7M2cQX#YWMR zMs>m*ynQV$b|YC4eJ-a8_1tbgklmjVG5+YLnyvK-5W2NKcm6DtyAppg zeCkG*15zy>2oHNMb53HEH@%pLP)WG<87AbTwvhxWInGp$cxOtD`F$~)ceSf4G*D3F zJMq;}fOVR;vn@&r-r*#{=w*JQaecawI{=o#+VJPMZzea|Kg=3Uc zdRtTQ*kHt>oAnWDS@m(YjiwA^;Yv!$&L4MX3`(?fJ^W>X_xel9QM0ZPi}RDHy}QI}h@#0cK!uS3G@? zp&Ne%YIa?rNL-LEGD$fKo)uc@ZGZSuang;MVx}&EER$cc$e{LDT0=Ci$IwI1ii#EF z;q&+^;0NbQcDBF97iFV-JZc<7_SZQ`aljd@?4{#qQ?S~XNc4P*1*j?$+YB<|2_>l# znrE))?>&w6{$~NV!LsqDZ!rlERHG ziqIZ1#V{ZwQJ$t2U8ebbomB&=VdY)1si}KRW|;L%e6S{J!4ppT^#06D9vE^Z|JISf znNYiV(P_t@j4WJxfK8J1`_q69Yj&Am&9O<|Xs=nw`rOjotSwYd$Bk|}>5hJVV@HJH zlX?wckRT1auJ9rRJ2}AjfE$S$`@5J5>+`E+)Wq%wq3{`cW&CP9t{;I0X`VGTmME6{ z!Xc57e2L}bc$>(v$JkE_#+iDX2JOUuI9CO??z0{weL>b=XxJldzUVZK6%F{DFp3QHuq zKBB~Zy+N&ify|eU5fa%}vQ-_@up)aQe=?Ahg1hNw$P&wEQ|k~ zMur^Q;btynXq zxz(y!RLqZ@Iwkpi9eGDWtAQO&II7Q@sN~tVg15WA-ZFgP$(Z%(hCb(NKpv;)Gc*!f zs+QitMct^-!#Qg)!18;}A|ueGWi5X15H|;Y*B}Z)wpBU|9yW`2k767DT{7Hzkw5eo zgvQyvnmithU!^_D8TG532&XrU?J6~f+Qg>)aL2aOs|3AwmuR5yRAw-IQtONnI<2^S z6%uj^CB3%1(K|6ET@s*jKBt@^hKdNP0Mf-qRcm6Rxlyw1R?nDntWYb-qU}5DHQV4_ zW-jk3{)Ma`Ji{YZc{ravp~t%(P42T`E(m13!gKk*snmBGAuH4o(DFv=f3DGqG4lsQ z!EYlCT)ZR+1ES3TZX4DAcGh|-atmw>cis&iL#wxj8+|VId>cYN(FwVX!y#z*uQcN4 zSfMUoa}UYg{8E8<8fn7hpY+UnTb4?{#+WCj=vU>peqEK~SymB8O!s}|`Pqb|zxQ7t z@eGI1t&YgmyBf_Ew4a zB`E?55*5q=(1`<9=7d$(*DNwD4#M5O9v-)#G>oNXsp7!S)YKF{L!e8XJc-UqX-muj z#-e?8d+YC&kl+Lija|WYKVmt%43T|eWQza3vy%Mkq2ZI)))xuEokWG0h^h!N@f`xz zW2d&TC^WxUv+tjC3mVBY7!StCmlPiS;?Bon6%p!;nUdM3i~YbXdyF5qzlLyjFcOR! zSI;0HF5}3o7LR~z5O1L`cFP%ztw+oqFk`hN&LjuxR}ttHwjZY6=wA2&EwF;R$jJq4 zRxl0MN8|iT^E7U6Em+;U(BZeqwhFGDU@5XKC`yTn61)40?3q4n7c`5#LK|Z)T<#iH zmVl@7Ypi9{3&7#2s}}tArum!HTst?=ccRXa^HbS)X4fR9KK_fj$1)aEcBRDu0Mq?1 zacY!j8L;b7|7RI{`0HDV5Gh*GfsjM^#BYRnSQTYW!ho>E{`2*ju~2(|xPpdWBMZV( ztI_QQQuckvUsw70=4{p7@zdDYii+6Syu8>vM`Qk)+)IWbOnjTH*vq2@#{yHH$O$ts z*Yg1a0t^BItDO(Ip!s%3JS!%P9%m2tv1t|I0Z<*5FjU^MPkVb&-fD=1<(m_pv02v? zv3Pt>u10VVO%~C)qMVMj46Lo(lw7z&VBWhqkCJ4SyXJ$a*O8Q94j02v9lkEnYf}S zSrEG@CvALsR}}Bjx>jeJP*oKzKVQ6X7aaxsRDM3Se=$2d*HC<;VY6mY`;eXua0ohZ zH3&Q+BtV_~^!PS)UKDWFCFzKG!1S>wN6g&t?Ka;p?^k0C>1wDch1KxoGCxUGWRe~K z7(N17>5&t0CR;$i&3}*N@;^Q&D9gv0TXJsK?!XSc4eIWxT>6v4$A8wO&sd#=NH`hy z@JLE_WBEdH7e-G65QacO6rCW6wMKOFUesuZpAI@{W2~!ogC9{iQ-@-tFTHOG^Ak41 zmMg42kYL&v{w%Lsto4S^qE!3Z;n<%Lk9YW}xYvXx)Zh!CJ&P0)@RG>HSMRQy)3R$t zmJ{8ot{8VPox!}GEuXQNUt9#vn_rBztZA?&AI$AQQ-5ITEAmN;82vY4Fg<23DnCf4 z<9wYuIy1O(3SWei@Rxp4L^5%KAY(u-Z{%o&I@=x4utiH}q;NnSk5WEtXix7eR&zqu zsD72v6%6gC$>a$*CD9Wk6BQF1X_pae!9F(I8{$~B2=z?XNrJeV6_nbPln5owNyjfka+X+Wji z>9ZAfDE#{q2S5HjsVcUEqP1Ho?tJ4i;nyRZCitlH&Q2&ux>EgOg^}w?_>5s%|C^W@ z#-t>ymwSJB3#|%!!ukmy3TEF5(rN(Q)`i7LS69SHZxO{iWWj`W$ov|N6`720<@@1w|NsLrrp$d*p#Gtij-9Mvd+I4w3 zIPB{%ztM<|CbYJ;a`M7{rI98k)(`ZI&o8-a#0+EIGITOTjSmENU5n9?1`&hdhB+j2 z<2M(>Smt+(@h}Ds71If-U)9@oeD5@JKrL}JhD8^>lX*Ro>1lnk{qcZbicAw&Q6|lG zCqw$3wF@89#4Mv?CquWLV1H~CX=GU`l<|p>MybNn@m9H3C-wE2Zl@}Q z+UKxfI`@pEMg`j7_t0@{dt?G^c`DL?+{>j927ey@9Uzj7Z6*S~s2PtTA^{1#EEI*# zk0*G}LdsMNB!`ArTM#u@&&L@yyXWfE&rXNm?rA9u`M{)95sHb0C6Y?fRWqlvi;ohA z&Bp5N6aOP>lG~t^>_0)i%u>*@U8YPTDwc`}J;z^;%86TeT)c8HI{gQL9$77KwF@`m zi2|Ui$bP43s$x=Qx{o*p^~E3|rbrK&lgq$P4qtzd!Obu`l3OQaOSp$ z^11zTXY9();?|IGnYpmE50F9;Mg>TjL^|qt!ISWyw%;)Tdj07?h4Hu8fQs;O`vlZX>pU(^F;X?V z$fHk^tvM2bktzSQ(qP@qlP$G~mQ^qN$~^BTe#&1cy#O7(m~mAJ5pAw#id zuW?ff_Y43{2wz_>NpZ-3w^9Is%AFrqxr|{&88E}B;#l)ba7as?d3BPK@BPJ9 zbaL65&U3~U=_LgXm&~7nH=;pA4t0XLHKkw6fa(7LbjFrGK0_4>G%}X4(-wg?a;|~- zZ!88PUKE>Cs}q*Kj_x9q1+(F+JR}Iu(dpW8~6^R zo)Nm4Nx;){o0lH$Qyx~xY#d)6JVGPUw!UQ#GT9CmlN3j1m20JL95|wmy-t{<7lI2` zO@!0e+-~}Jvi!lCZM;9U2`9ws$dM@Eq}CkAiYn3%P_Q#n%=2@&1iHGL|qTLj11NkqYJZWdU{$c~fyZ~F`aIzakALt$ZcT(0S& zB*wc}>JOHDRce4ZR9l9R8v{0j4}#g1zq9?}BSs4OQ@()=q?o5n$FHod7vM@Em1 zm+M*$yh%`X?U6*NPkD^$rVw|$CbTi+vVqk0M`ji-%UDuCZbbn+NF(-`AJF3HeAPij z#HcUnanOrXG0|P@?7a#h(-RCQUvOZ^l;{93iS4=k8Qa!%q|~3j%j!$xaQ&zYhSV<# zhVK@^ngT$VpHas5V zZ61-`Z**N${tg|ZF<{>VM2;U8%EG#D9NOZ*y7?H4pf2|!sgNkB;UE&XyyiVx-id5B zNvT8Wj>y^5D_7yrW@e>m;>h%RHST%pNzbpqhm=6Xa@q21lmfSrbM*-FO54oKn{!Tq z-i%NOc?09_1c=2|iq2YNx&kZx-$iB-lI&g;YPJcwr5IDvis@v@I>3@C{XhHomAP5jx-{cNi#zP>NH zYZR*}23)>Hk39>@lusS?3u!mM!iMvxN1y38?{G#N^z1()V8*v%9V6^g!xNglwVSGG z9ha)9(mG4c__|Imdh~wj8_c8bU`$D!Hb$x}Xq>L@yJ$u&*x_Un6FT`zxJ3ubi+-h* zn`;}xiFZs)AkdVO&|-?LVyEP+M#9_LV#P;mnutT}sX}b6&Zu#4)}(Dti58}1O16le zhBZojIlW!vb@uwY!uooz70mTy*}dMo9~iR#@6r7j^dE<6tFYOS7z+u}Z9b`Y63e9e z0g%#;d?4+EouT-6Rm}pnMyFi~>jGlMgc1%hpW%9OS%@B}a#goA!kaOp)ueXf)gYk? zTKD|MKR4th4{MxL>g9W#(OcLqNmDo~UY+J{6O_keuvFyNCT!S6vhNEEqTUvD@t!mXv zY`(JFG|x57PHH+`YaJBcr~tvqOlQo%@zbBFoQuD0WO1&4C2&(pV3Yf@>Ck{lA)f@a zX&Ns6){#^i$bB?kV#@EBJUg_4AV|zMDiL4J{~@+_$X4nxKL*HC2KWT#?R6pL1;b<` z-7qYjBE5|jGd|e$AzMc7!zI4xk~F}138a0x$0cfahT9RmEmymq(mAi;I`F0)Zxp`*SJ$Nu27sFL-X zP+H*O25>>5cMn+9TK^cq>p(V_Ad^i`EYhA?3P{+Em}j9**GiH}I=t(!G5$#kpP`cD zI-OP;$P))V32$8iMugBA7~0+Aj;jfM%;-Aq7O9*wmOx;*zcUBlsPXS^-4*$6f_{3D zhU$h$6>d#`1^A1j_W$}t37ovSTX%ARSdQB_sM6H#O4*Xq_0M0b06YlNpsN9KhrGIO z?n-O8;dR|?wuQD^1C&X?vi0W!j)j8G6UzIbc$Vy5cT!ER@#02?ZcQCvPJvrKGc|5j zof2`$)|K(1B_fQxe=RW~tR294u8TTK6pYe8$vheJZN4_}F>NwJ5XlDa= zcO1B4c&ARqN41j{6f)|Iu9#5jNw_dV8?G!xSup0jhvCOrYe6=`ETMVH!Tt{Fbp&!pn)EY*>H>5>-mH-%n`95Z_)%aSSEKmjQ3-qtX ziW@l%pUpbP%;ZirGW5({`p{t)!Ikj%U4Gzeo5=|r+7RItrwOB_o?z)=F?D(1m{Zs( zcsthPh!E;|`Em-cLL&8Fig65TGnNv=8f5XvoYg$R7rXX%Eo5f&Kae@@dgP02TTrcD z->CY$#-N!9GxPaCU3s0hUroezONoo0W;tY~H z)!CglYo+SCrnjzPpE-SN1|H8Eoh$qJe84a3uKP--F3Meo*ogsbjBq^Zlr2pOwt??Y zlN$lkN~9hI2?FqOmLXx*1huPd@TCp(HYnw!=qSrkOxxV|GwJWpD|1=wO6M(jGzm&OqT>YEk_6^% zrhHUdgWW+I75BLp{J&(Bq7($4Z*>aR#}Z{Hxt2@H;%TdV8Q=9-&S^4u?ph3f)^fzH z(`mE3Y}d=EaV!5bn7;&3%DH|E_=u4R*Yb*%-Cr`ALPr-GpPSt2cxWQVmU)*i)s2~W z`1iHmyAYBU%%b&zGc==fsm z_*+p52BKv2Db-Hl#M;D0=GdLWiaa-NB4%8R?Ca;~p;+3h@Dz+2zBT20i+3|FwvNR` zVz~lwgm$jNFaJ$i#dxfh3E-O_NH)K4<$8p$yY=OV^0HZa7zT4)CbDF)?YsQ`>`qzs zf`?V|i_w(RZ;q&wDG4Z9>f$lCD_I+*#c9u!J)W6JDe_0(@$`rDpKjPV64EQWcDt<~ zYlfXrB2mEIpPb91FIa=4G>4*?3UwRlrO;#oCmO>=?ltRzqck|XEzfE-qjMVFhI}Rd z4`JzW$l?})FQ;M;B>VIU7|4nc77Q5dKY?RQ+HjVL3yyg#AIXD*ZluMxeNHr6A7qx* zVWix9Rbo0HwWV?IVxfY7&_y?D0ur@poczu4)gOrT+GdYk{|w3ZE~wwCym7mn1-Sbn z_^C&sU+a#)v6>TfEXPgL6S`&`2O!hl8{J|sK`IXKJG-NuBc;P8G$>u=OY?k)<9uv8 z|2d1AJAo}r=2QQD_V^e6I8$EGd?Xs)(HT_6t!K7P^Wy1e_~z=5QNbA#=<2RB0}OO)nAgat2-;}>7jWAde{~_w8Vemj zm?_%wIUV+QLE=x@I$58ZcQ@8>89tl?``UN4DS}y_u4p;+Ar%>&Tt6du@w6LRPNMZvn z{pik=g%2{vy1~tZ^zw9P>HGv?ysIukC6t3m>1Va+RnNSX!KipZ5pvqHtF@Ezm*e_B zV4r?OdeK7EyC7o_f&S4E?}zbczf6|LdnNv0B8s*#WLFU=tKjeEP4a*0ttR_R+t}dm zocWMB)zD2NUOdygc`h;?5koLAggyTg5vxa8OEc@ZlZ){dV4476m!FO3J5IC)_7MQu zOK_FGf<8X7%c7$QWG*~yJI0U-o$2Be!(*z>i0pfR7~@(O|CxOYc&K%1*=gnRqVxcQ zMbz&J?K@=r{-E`zpoDVbw_oTB3$vvMotnpY*0Y7A?J0e)G$dzFu@I(J)VlwT%WQ1S z>MY2?kpq)i&wyOD)r*MCQY@L`YcPK+E`KR!XPCG(i{T9_(3CHS4pqAFmJB(_M%c2X zg4UkHFr~+Hb}*^&E0H#o3a05(kUbJNlbM6OId6 zcA<6Qxr;Ir8ng9XQ1p$7weB$rlTGOR+G)PTk4AcH<*I7R{}ED!OwUh4oU0?nk25BR z;4HW@n`-gWd<_Qds;l<$LDV^Vh(Tj z&|iU);U5*(oERQy$8KW>TOCoZ1&*TPWNpjO*u*zHo%t9z7?~POO|bHxS5}TZd^VJ4 zr2an78Y5xZmiLUrSF#n0Rv;vDBr(+50_bcfja*|tKihPXjAmAUPPn1~eYi4el5${f5XXty?8jOEnVfDXsU1fSF*@+Z#OCB;BK~O^Dhi5s z0-Pa8ZZ0QJNA)2?k<}pZp}&t+Lz`k@Q!}t}R?=f_W4Lu*`TGqi$$-m`DF9`)dmH0# zLV*~meBn^ANhylO^tG%NxvcxQC({FN(F-MMAiOgPj=~9d+)+Qps?YTJIb`)F^S-`9 z^Gf`OK1`$gar`RALPkRlp|*FTSut*mXVjblTT8M$6)(Pm1A8@2E&;$jjCwT15oo#i zx?M}9aL=!DmmPbSFl1%wOd3FNilsjQ+}cW5G&X#a_MYhDW{TJ@{D5_>LWypJjsFYrP@%*MS7a zj%?(2J4CWrF(og`GAO2EkdKUy)(DnSD7?Wk_Ys?qo-ml$NCy1dEc!JFzGt{;ic)sJ zFhA#m;5LERj^f8m%+FWfWqPDY6O8MIZ-%hgoy|)sM=&50mX>ZBxLcBiAo%aXGwLYp z_Rle#f9+uLKa!8aXCF4h8sgtRs0=?ZYb{A#{}EVGlcY>S9mCuMA>#V&YHlEmbT6KY8!B#n%z%%KsQE8 z-+sPs0Rf8FTSTZcH(mb5s3ihOp@;Z2QgP)8akF z{X6sJsgBJ1h6+>CX_wPyFuKJHY(p`{u@KbQWd?9QY2f>Q8v9rm)zmaxca<#=8N*^} z6?tTiOz>YOanRfVrw$%c+WI^WAWH_kjx;|mHWwlZ3Y>L(X zsj$?Y4>aQZ-e|f2ppm*=QScCIpd!>k03%kp(PT8#P3FV){{;mi`rWQdK-_ip`!g>h zOG{s34TG%iT&-eF93w|Koa#g2!qDG;a|U#Udz$;;!-furo-~AH;xF_Wl9tk!ohexv z&9bX5CkT8#tFuAwJ5aFe|8#0v$fQyl(|0t$*qqwC#*Fc_Jnz;Qee0g~Zl?PUX=HmvG)R%rrcJ|SrxmcZ5vJBFk_7S_yiBT5&Fzi_wd z2fCELAj(+6Xmh#hb8;LLm`@Wn01lu7@c`^-Q$KtN?`q&e10zt0urHO4`Axd#Ro(YL ze4l@LWuu=d#;+ z2bi9S>OEK2t0NAl`aqY5&=AEiCvbA!U{79Xo_3>36Tt59Bshd5Cq}^L^LtrC&pg+} zC0gbSJ&rbrQh%Mm%O%*oVBGbC~~`d=&oM*|Uo70ABpAd_a@jOLc?uBNe_BJ%Lm__KFT` zx`qy89W-MyV%if|Fog?ulHJF=WL5gX-2yvHZhxv||SkUNezU2g3V z3`>-aV8zLiyfxAvngN6LIYp;M8xyyIdLb?7QK_%U6CNSQSG+=j*&1Y zN#t;PE)vd0qpbOXb(0YJGW4|CTA4={lLZcSq0e$IT9kS>IojcdnG2jBak>q)xYklu znH;sHPhGoR&J%U*7UNr7TbP^~gqJsAnzpOGgI5O$#{O6=J-<>zt&$y0&BuQ`W7b7! zO?P%7HB!Bi3%G_z1kOd2+mKM?P!*x0~0*aCkLW zL(g(BCL_9gm^ZiOqAa-^0pTY#xLI~jz!SoG{`xkl%-u70_#vX)IG2p=Z5LLR^^JW8 zN;cebZI~o+Igd1^hm6O=vOd~a*5mQe!vWufu%_$tu!`;*h`SI*V;<7MS_ ztNuLP%ljJ2N5{KPmuar=fK_WTuJd#`oyR)t_NH3S5{wRptx=aPvsR;4m&R;+%DnO# z{REFXUHV*~l~X2W*@Uvo`Ib%K{%_3x$bGdbt zTgw;>b$_3-48!HH)3V+6OHy_i{YAN?tli@9Q`KSEY1wZ3B`KRxwzkTu!BBUS0Lr#o zw%fi*$_A^9OO;#8lrZ$8?@3yA*lF2r`y?s53|6`H1`_ij%ZkImB}GohFYKpixBZlq z)y>vrMt#aPQHC0Z!*R@hj&^H$J4#SC-f~tZE2~$M7-|@fqxN&OTeI8S4u`1}S9&j$ z=>!4eO7%CoX|0KPi#LyKY0 zU)!xY_Tq56GH0!AA30QUR&FtFCZP-D+S?D?6D4+QMmv7Ec%rZT@{9kmclNw(BWWC$ zr7+aXh--_^#<6_{j182=hu{FgfZM(18Ua_xMIaC$phgq~uu;hmkjDHB>eH&p_IKTN z#sRy9iWL3`4v+wgotfvE7fQ0j(W@nMoY)dc4TsiGfB)y1XNC!fVQZt;(DOUa?DF$1 z*VnG=wiYR_dwqR9$C=+&m?$6c<+UEam(F7>tJpF{IkU^pm14K`ZfT|E8sdCPDl@V# zj8RsLda{mRg=BX5`Ek5NjAb#v`R8+LFIb|oCd#&Lq{?O|$ z$;TBX@~k$XxUaN_-yo=vV4A)y}!?m@bX$i7w<1 zV17dc$$Q#Dw5BApj-P&K5||prbU9;r_dT+y512n9;yy|;Asl64FK{B1ZHTg*dMzym zuJeUdOlIs?@5U)GmpVo zrhUIGCbwgy(Qwx+$w^_>SU$0YCS&wMj#%6Ck}MVyn6wy_Wh)!^+qL38(g|CGJy*KS z;>puSncc5pPJ1!I@=Fn2=E)dTmot`kOO)L;_V;FK!O$*s+4vd;4UxkJ==wHUB|0qORO zk&LnB3A+q)N;1Q|JHdIf49gA2zcu`fs3z2(a=N2rs?w7%0h&Tod zWMmUdcBjj?D7}|Acb~`Oj~_pd-`{)fe!t)LqWkfs0n4vgK)F&WGxd&ue?_w&w+5;CCX1GO+CT(%mZ_ zK2JwP&$d@LYbY%RWxh;pu~^je@TD$OoU`E-(G|fTHYo z+TA8AT`J4HF9zX2_q;-Va#f$qw;ZFax_)YUlvsQVV{#-D=IvJHx|vbF8sFc4{CuN^ znJ>qTV~Q=8;eI;|dqLnw9BpQo{oV(YuHOmf5Xs=h;t}PE`PU-fQ+W*Omx*IJM;Xl9 zYA;+PqkQwpi#k4K^`GOrn@c9i_Z|iL0t-bsjt9LSW!dwhs6!7O#J{Jk8;s6zUZp5Y zdy!ir%Oy4n{z9>QptDR(Mi$E0Qk3gP_Tusaq?Tdo`}hBPKaSc8WQuaP9gad;D9(cc z{Tau@+dooP4i@7qIW4ugtS+B6R8rD)xs0<)Fet}5fXCVTh}(#=Yho`N-?1~tcy3dk zedyomDEEdVEW=w`zJC3!Q8$w1!$k0+XfGoSHyOrA_+`AQ9}i^k&YxvvE@KP*g;Dh4 zPJ@;L-S^tf1j!U-&u0V=hr>}!wVBHDxAEPTnJkz0UcOL^b}rRk$oo1JlYY5eEmTpL znJ_y}W|_CtDW^RCzn?CW;3hfdUfB!c;fVSRW|qN+1pdG8Qnm?pS5$#FCfcF zk0FwacvOyXmYt)_Gj9#aSJy^?xbHtAnTm2J>;=BhJhUj?eiZz9Y2cRK*AP)I6-&$Z z$=dWNky?Z-i;N60TEZ-|lSMPOh2^WOmPz=c@k5fSCn+Gxo72PikGy@-4^LJ}~u%)5Q8<43!G zL7dhsyWlXWDzgmb6|xK~$qAOlJZv#sA{;aJ05h7OxG1B)NS#OY1FPJnBvWWv_+mhD z_B*2ZK{&cedlGOM4CT^FQMRFeg|dXX05FTd91AmZnoqMCfKOMH8)nI!pB~p_W-t68 zz8%H1gyBGyaP)iXX7NwgFEDdtrK+4O!5k?JFiVrcb28-UAG!=ivqgP#CeBY(l+EH7 z4K^&&e8ebwl;mE_21xiY-z^N4(DL=StJDnxhp~E9hV}4Eaxpo*_XkPLT$ynIki$%I zt~tktr~BC=Fi$Ri;Tl9i_~^|q8PHEil));e_7|7Ut}L8pooFG-)J(+8QuKnz zYqxP$RHygnA88XY8qzEmSIe<98Iw^tFkVQY?Sp9~YK-JV+S9lg zS)O|}<#`k363SfWBPNCF1kB28z^}fN;%%^u-#}}6vz3a5`52PhtQ56c23yy4{k7U# z!?iCi0OB_P$6#)DL6)1`Uqs+9SR5|!VYH7QPD|tuz8RV2S1{f;*BbY8E-E1MivY1?Xf~Q2gh=e~Gx2cnHP>K@q#)jgYUmbfDlUtlttFxADXfw^VV6wgca8cqWw{evrh= zv(2)?Hf+C{!RgI zE1ET327>`8^Z8J6v@hbLeVriwyJ-mH1q7DY!7^JLQCsds?*JY^Xbydb-VG$$PkisO zcZg4=8G+|Sdk?h-M8`($CUU2v*D4@WMu#{S(d#6MYV_~2f%sz8YVByf$+G1{+pZl= zbm}*9{SE|3Yqc{olpEi*vZC#fUu5CF*?Ghb5wsmj0z-FOQS2QuR!;x`FHGI8%f1%5Wi<@19no2v|m%Sz2-@7=-cg zb~uuweIb^3^eug4xdq8_tm9X*b8CXxhLn$DF`>+Rdwa4A$WuTyajxVk&xsD9R%UNq zX0fKvvhnC1`ocd=sitG=^6Ig;ChaI}yf|_r z*DqK$N4d^p69L-au-B0+!|E*&^~51}7HmQk4|4E%*%bRP4MUVq_du4{igGCjGo|dL z&5+8>wV8JVQJ|~9bRo&LRvz3j_knYi!i-ZCmePX4(k^S>>t{D#q_S*`@(1RBS)Y!T zQOKhd5S4?0kPZth(_KLz7TSV^2E1VK;c_07SDhR;!!qY4JAfL^D9)lM$UB4ZpDT4i zNH%|vd@c@Q8}LH_k8l?}5=V$K^)k6%99%7^%Br%4LRCLlJGLrbEvi)YSF3uX^ya_@ zywrkfEu0T7awFHDXJ6c57^D0-#=^RCqtO6Z24UvO7Jk4`4q!eMfXr=lls@-9f%2L` z*6zHjl8ltm5aNv183J{v)2bAv9_UXC>Y2cyFyplk7x~`L-dRXfk=}?Ir&gP_52}6v zg~B5(UgPx__*_id@iXrB)i1xXg<3{KT(Bh`@b$F7NuZSonJV}AXrCP7X4WJ8hhevm z&$=$!Ye$*83{9CQaNZLI{6-lR8onP0zU7bUQpWFPd{_nxuGKL6Wo3Nv1ILvQ5ufZjt27ZBjcP9|~a zSCHOd0Y1QnOss4IW3YiTVE{Q~Fi#+007YBpQq|QYMX@iu3~+v%w`N~R_s8F-PF0`U zrFLJi8JKBgMl_dZA7~0W7AbUh3)jGh>}uf~d!E{PICrnFyr%zLW|oC>b{uBj4`)90 zCci({;xWG5d7m5Q;r-vfMh0AD`QO|{O_%nOEq_b($+-2ua(0mr%4!27Bg_GY=@w-N zv(IyeXwhZ_EE|;H<*yKa@uwm9k3atW@9#e*|M}k|uxt$Yo9i~YflK?oVku8b+hIn| zgwVNr`*t~np*O;Eps0SWcGt3cF#9wk(KAoH=i$_%aQ-WAAr#CD>G`}p^V=aW58bb- z>9z}TrP?6d6IePeVtrctBsQE#u-5mI#y$JvGZJ@MQfL_<#xN( zuV7**iZP&<8k=_p$D0HKzQ0n-AW335@n0qa19BFh8d#tAR;6FV-E<^h9a9z(yjP=AO_TWNUx*NIQe)H{fLRMv-l2+BMQO>6fD| zLOSAW67ByLyes#NH;6P%tW1ZB==0fw+db1wQ%!6BPVS&~kNm}=E&2Xb?cEEOdnHjm ze*jyClpGd19Q`b{GyHX(kt^dM&#gn*??q93DXq3=1g?9rCx_dMbRGlk&NZKBhnGsv zx;)TqC)66rSDmEr6MTaNSEe!BA+BqS{P-z5xJb((^_VAN}xn|+tiTR__)EltBX9P@&;bK} z#cR`3EU?FS?(p^}v6=6WzHAzPyCzt!Cc-$sydUGpBFJFel|z|Ig*O=GHD25&`sWFw z7qaD6qgO!=0|qh^%HI&>N%{C9uI=r@AkS?Kl|@^GvSyU)t@x;#2;<@fP=?;VFTahS zhb2vU&^IXacx5?XqD6A;zPLZAq~SN5jDLff`Vi$Dbpqy_@@XS>X=N<|hGGI z6UvcN0hRmEw>n8B zw)_j!*7|&d*FF_>WeP7&I}l%_Lp%j#(d#J;hcZSOR5839%NH*m%0**kYJm0uWzH&V z9SF*z#VFfgxfXXiCkJIijN!u=QT_~*osA1-FsOIrr~~l@p-f{=xd`z8KvW4=)Z9W6E{_1`kdViC)iP1j?o3;!aXJ#27wf#egqgU|O9~ zp^obwhZhNk%4EtCDvRDCSt;tfHnt2AM#3S+Rq;@HY|Gr=6&vJ9V><^QlO%2QDG8ND zZ?cTz?zXv^KBQY=41`4%ixTjUy5Ci$QbD7R8m zmh&WfyV(a9n{Yl3lNd?DEEgM%aeM#xXlQ8H}p8gt^B)P$nG9{G-Wzkac1^4*v)rn>2%f<7{!@@lyKynvQcHJWp zUdJfsN|eWzYlO1%<tl z{=r~fFkf3s9-MV8$+pcGQ4JDdS+q>BoF?Q#D1lEQMz?!t0jr=q#!?_3>~{g>PMW>2 z!#jm!(Q>v-X$;C@P)7-}e0Gq+ysDVF=MFJAJV8%}&XHQ13c2vaP(re3=_Cdg3g%+V zl*Z^9m`_q;9$fXVS7^x2lCjALfwEsRKu{Jfox}(@AxE_kDaHU}?w<}kU0qj)^L5YT zc+fLZGNBA4QyvaUR)LT#%7HSJfaDOvV;Sk%KOSUsb=B)#ms2l8F@St@(siB;Ra|zt zxK@!3h&CpnI5o+nc}Sx^?`1ljhK zP`q4k#c_sZhxbR4(nFqv^MFqlTmGNb0{MjHQ>8?@)GB)_yyP@@Do})s} zj3__LJSz|RGQk`-RtHe`dKo&iVH0a$aNfbVLh2k+zZK~Qr4{8)qaL$a^_mA(XcT4>MnRinU7?VIus)t zM|PHsz13RFaC=cT)|_VLC}gffX3q2G3e+Kv<5~?I*?{b_$uP6Ac(SOHcp0bLNntk5 z4A$IgHHhYl)QIDjY3mt|Q%9y@7c|+coDES)m^HDam}Y{RtQozz0qr#S+i9S&(t?It zZgIEk-~xJsw!w7`#^}e+kl~6{8r4ZNC(Ef2Rg{y{HkDz6W}vxkXg2nYioF{zFi(xu z#<6`tPw_Es`O5+0lw$%JXJilD)Z!vhB{btTQp$^&ndZHA+t@RxR`+Beug_O|`Z?XT z+q)cCSdb~1EU|^CQj)3DSfx=0(F`~no&iF*?HRJ+wo6ZU`5kjOS^#&@y8JdIm zOmoi$Ug=K@>5;Der)yIbM%>CKf^?W0{u1C(xfnc< zSyDxtaOZi12SN4{f9)Q0`dVjJr*~yUvPdKni9{liNF)-8L?V$$Boc|XMgIqpruZz* SXBXB00000e-4qS2H5+-1Gbm{Q#{oS5xf6_ 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2a0e16c1bef416e6e74050afae0e8d9ff16f76e3 GIT binary patch literal 17933 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 Z_yKFOL+`f@_f-G@002ovPDHLkV1gh4C<_1p literal 0 HcmV?d00001 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;