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

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