Update spam UX and reporting flows.

This commit is contained in:
Cody Henthorne
2024-02-09 15:25:31 -05:00
committed by Clark Chen
parent a4fde60c1c
commit aa76cefb1c
66 changed files with 1578 additions and 894 deletions

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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)
}
}
)
}
)
}
}
}

View File

@@ -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<Unit> {
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<Result<Unit, GroupChangeFailureReason>> {
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 <T : ViewModel> create(modelClass: Class<T>): 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.")
}
)

View File

@@ -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);
}

View File

@@ -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()
}
}

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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(

View File

@@ -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)

View File

@@ -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<Result<Unit, GroupChangeFailureReason>>.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<Result<Unit, GroupChangeFailureReason>>.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

View File

@@ -366,7 +366,7 @@ class ConversationRepository(
fun getRequestReviewState(recipient: Recipient, group: GroupRecord?, messageRequest: MessageRequestState): Single<RequestReviewState> {
return Single.fromCallable {
if (group == null && messageRequest != MessageRequestState.INDIVIDUAL) {
if (group == null && messageRequest.state != MessageRequestState.State.INDIVIDUAL) {
return@fromCallable RequestReviewState()
}

View File

@@ -132,9 +132,11 @@ class ConversationViewModel(
private val _inputReadyState: Observable<InputReadyState>
val inputReadyState: Observable<InputReadyState>
private val hasMessageRequestStateSubject: BehaviorSubject<Boolean> = BehaviorSubject.createDefault(false)
private val hasMessageRequestStateSubject: BehaviorSubject<MessageRequestState> = 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<Unit> = PublishSubject.create()
val reminder: Observable<Optional<Reminder>>
@@ -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())

View File

@@ -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()
}
}

View File

@@ -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<Result<Unit, GroupChangeFailureReason>> {
return recipientId
.flatMap { recipientId ->

View File

@@ -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)
)
}
}
}

View File

@@ -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))
}

View File

@@ -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<GroupRecord> = getGroup(groupId)
if (groupRecord.isPresent && groupRecord.get().isV2Group) {
val pendingMembers: List<DecryptedPendingMember> = 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<RecipientId>, avatar: SignalServiceAttachmentPointer?): Boolean {
if (groupExists(groupId.deriveV2MigrationGroupId())) {

View File

@@ -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<RecipientId, Receipt> = 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<ReportSpamData> {
val data: MutableList<ReportSpamData> = 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

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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<RecipientId> 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) {

View File

@@ -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() {

View File

@@ -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);
}
}

View File

@@ -805,11 +805,16 @@ final class GroupManagerV2 {
}
@WorkerThread
GroupsV2StateProcessor.GroupUpdateResult updateLocalToServerRevision(int revision, long timestamp, @NonNull Optional<GroupRecord> localRecord, @Nullable GroupSecretParams groupSecretParams, @Nullable byte[] signedGroupChange)
GroupsV2StateProcessor.GroupUpdateResult updateLocalToServerRevision(int revision,
long timestamp,
@NonNull Optional<GroupRecord> 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

View File

@@ -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<GroupRecord> 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<LocalGroupLogEntry> processedLogEntries)
Collection<LocalGroupLogEntry> 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<ServiceId> 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<MessageTable.InsertResult> insertResult = smsDatabase.insertMessageInbox(groupMessage);
if (insertResult.isPresent()) {

View File

@@ -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;

View File

@@ -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> 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> 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 {

View File

@@ -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;
}
}

View File

@@ -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()
}
}

View File

@@ -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<List<String>> onGroupsLoaded) {
executor.execute(() -> {
GroupTable groupDatabase = SignalDatabase.groups();
onGroupsLoaded.accept(groupDatabase.getPushGroupNamesContainingMember(recipientId));
});
}
public void getGroupInfo(@NonNull RecipientId recipientId, @NonNull Consumer<GroupInfo> onGroupInfoLoaded) {
executor.execute(() -> {
GroupTable groupDatabase = SignalDatabase.groups();
Optional<GroupRecord> 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<String> 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<Recipient> 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<Result<Unit, GroupChangeFailureReason>> 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<Result<Unit, GroupChangeFailureReason>> 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<Result<Unit, GroupChangeFailureReason>> 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<Result<Unit, GroupChangeFailureReason>> 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);
}

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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> status = new SingleLiveEvent<>();
private final SingleLiveEvent<GroupChangeFailureReason> failures = new SingleLiveEvent<>();
private final MutableLiveData<Recipient> recipient = new MutableLiveData<>();
private final MutableLiveData<List<String>> groups = new MutableLiveData<>(Collections.emptyList());
private final MutableLiveData<GroupInfo> groupInfo = new MutableLiveData<>(GroupInfo.ZERO);
private final Store<RecipientInfo> recipientInfoStore = new Store<>(new RecipientInfo(null, null, null, null));
private final LiveData<MessageData> messageData;
private final LiveData<RequestReviewDisplayState> 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<RequestReviewDisplayState> getRequestReviewDisplayState() {
return requestReviewDisplayState;
}
public LiveData<Recipient> getRecipient() {
return recipient;
}
public LiveData<MessageData> getMessageData() {
return messageData;
}
public LiveData<RecipientInfo> getRecipientInfo() {
return recipientInfoStore.getStateLiveData();
}
public LiveData<Status> getMessageRequestStatus() {
return status;
}
public LiveData<GroupChangeFailureReason> 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<String> sharedGroups;
@Nullable private final MessageRequestState messageRequestState;
public RecipientInfo(@Nullable Recipient recipient, @Nullable GroupInfo groupInfo, @Nullable List<String> 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<String> 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 extends ViewModel> T create(@NonNull Class<T> modelClass) {
//noinspection unchecked
return (T) new MessageRequestViewModel(new MessageRequestRepository(context.getApplicationContext()));
}
}
}

View File

@@ -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);
}
}

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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<GroupRecord>,
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

View File

@@ -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")
}
}

View File

@@ -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
)

View File

@@ -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()) {