Add basic profile spoofing detection.

This commit is contained in:
Alex Hart
2020-11-04 16:00:12 -04:00
committed by Alan Evans
parent 2f69a9c38e
commit 3dc1614fbc
30 changed files with 1726 additions and 10 deletions

View File

@@ -0,0 +1,140 @@
package org.thoughtcrime.securesms.profiles.spoofing;
import android.content.Context;
import android.graphics.Outline;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewOutlineProvider;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.Px;
import androidx.constraintlayout.widget.ConstraintLayout;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.AvatarImageView;
import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto;
import org.thoughtcrime.securesms.contacts.avatars.FallbackPhoto20dp;
import org.thoughtcrime.securesms.contacts.avatars.GeneratedContactPhoto;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.ViewUtil;
/**
* Banner displayed within a conversation when a review is suggested.
*/
public class ReviewBannerView extends ConstraintLayout {
private static final @Px int ELEVATION = ViewUtil.dpToPx(4);
private ImageView bannerIcon;
private TextView bannerMessage;
private View bannerClose;
private AvatarImageView topLeftAvatar;
private AvatarImageView bottomRightAvatar;
private View stroke;
public ReviewBannerView(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
public ReviewBannerView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
bannerIcon = findViewById(R.id.banner_icon);
bannerMessage = findViewById(R.id.banner_message);
bannerClose = findViewById(R.id.banner_close);
topLeftAvatar = findViewById(R.id.banner_avatar_1);
bottomRightAvatar = findViewById(R.id.banner_avatar_2);
stroke = findViewById(R.id.banner_avatar_stroke);
FallbackPhotoProvider provider = new FallbackPhotoProvider();
topLeftAvatar.setFallbackPhotoProvider(provider);
bottomRightAvatar.setFallbackPhotoProvider(provider);
bannerClose.setOnClickListener(v -> setVisibility(GONE));
if (Build.VERSION.SDK_INT >= 21) {
setOutlineProvider(new ViewOutlineProvider() {
@Override
public void getOutline(View view, Outline outline) {
outline.setRect(-100, -100, view.getWidth() + 100, view.getHeight() + ELEVATION);
}
});
setElevation(ELEVATION);
}
}
public void setBannerMessage(@Nullable CharSequence charSequence) {
bannerMessage.setText(charSequence);
}
public void setBannerIcon(@Nullable Drawable icon) {
bannerIcon.setImageDrawable(icon);
bannerIcon.setVisibility(VISIBLE);
topLeftAvatar.setVisibility(GONE);
bottomRightAvatar.setVisibility(GONE);
stroke.setVisibility(GONE);
}
public void setBannerRecipient(@NonNull Recipient recipient) {
topLeftAvatar.setAvatar(recipient);
bottomRightAvatar.setAvatar(recipient);
bannerIcon.setVisibility(GONE);
topLeftAvatar.setVisibility(VISIBLE);
bottomRightAvatar.setVisibility(VISIBLE);
stroke.setVisibility(VISIBLE);
}
private static final class FallbackPhotoProvider extends Recipient.FallbackPhotoProvider {
@Override
public @NonNull
FallbackContactPhoto getPhotoForGroup() {
throw new UnsupportedOperationException("This provider does not support groups");
}
@Override
public @NonNull FallbackContactPhoto getPhotoForResolvingRecipient() {
throw new UnsupportedOperationException("This provider does not support resolving recipients");
}
@Override
public @NonNull FallbackContactPhoto getPhotoForLocalNumber() {
throw new UnsupportedOperationException("This provider does not support local number");
}
@NonNull
@Override
public FallbackContactPhoto getPhotoForRecipientWithName(String name) {
return new FixedSizeGeneratedContactPhoto(name, R.drawable.ic_profile_outline_20);
}
@NonNull
@Override
public FallbackContactPhoto getPhotoForRecipientWithoutName() {
return new FallbackPhoto20dp(R.drawable.ic_profile_outline_20);
}
}
private static final class FixedSizeGeneratedContactPhoto extends GeneratedContactPhoto {
public FixedSizeGeneratedContactPhoto(@NonNull String name, int fallbackResId) {
super(name, fallbackResId);
}
@Override
protected Drawable newFallbackDrawable(@NonNull Context context, int color, boolean inverted) {
return new FallbackPhoto20dp(getFallbackResId()).asDrawable(context, color, inverted);
}
}
}

View File

@@ -0,0 +1,77 @@
package org.thoughtcrime.securesms.profiles.spoofing;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails;
import org.thoughtcrime.securesms.recipients.Recipient;
/**
* Represents a card showing user details for a recipient under review.
*
* See {@link ReviewCardViewHolder} for usage.
*/
class ReviewCard {
private final ReviewRecipient reviewRecipient;
private final int inCommonGroupsCount;
private final CardType cardType;
private final Action primaryAction;
private final Action secondaryAction;
ReviewCard(@NonNull ReviewRecipient reviewRecipient,
int inCommonGroupsCount,
@NonNull CardType cardType,
@Nullable Action primaryAction,
@Nullable Action secondaryAction)
{
this.reviewRecipient = reviewRecipient;
this.inCommonGroupsCount = inCommonGroupsCount;
this.cardType = cardType;
this.primaryAction = primaryAction;
this.secondaryAction = secondaryAction;
}
@NonNull Recipient getReviewRecipient() {
return reviewRecipient.getRecipient();
}
@NonNull CardType getCardType() {
return cardType;
}
int getInCommonGroupsCount() {
return inCommonGroupsCount;
}
@Nullable ProfileChangeDetails.StringChange getNameChange() {
if (reviewRecipient.getProfileChangeDetails() == null || !reviewRecipient.getProfileChangeDetails().hasProfileNameChange()) {
return null;
} else {
return reviewRecipient.getProfileChangeDetails().getProfileNameChange();
}
}
@Nullable Action getPrimaryAction() {
return primaryAction;
}
@Nullable Action getSecondaryAction() {
return secondaryAction;
}
enum CardType {
MEMBER,
REQUEST,
YOUR_CONTACT
}
enum Action {
UPDATE_CONTACT,
DELETE,
BLOCK,
REMOVE_FROM_GROUP
}
}

View File

@@ -0,0 +1,74 @@
package org.thoughtcrime.securesms.profiles.spoofing;
import android.view.LayoutInflater;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.PluralsRes;
import androidx.annotation.StringRes;
import androidx.recyclerview.widget.ListAdapter;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.adapter.AlwaysChangedDiffUtil;
import java.util.Objects;
class ReviewCardAdapter extends ListAdapter<ReviewCard, ReviewCardViewHolder> {
private final @StringRes int noGroupsInCommonResId;
private final @PluralsRes int groupsInCommonResId;
private final CallbacksAdapter callbackAdapter;
protected ReviewCardAdapter(@StringRes int noGroupsInCommonResId, @PluralsRes int groupsInCommonResId, @NonNull Callbacks callback) {
super(new AlwaysChangedDiffUtil<>());
this.noGroupsInCommonResId = noGroupsInCommonResId;
this.groupsInCommonResId = groupsInCommonResId;
this.callbackAdapter = new CallbacksAdapter(callback);
}
@Override
public @NonNull ReviewCardViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
return new ReviewCardViewHolder(LayoutInflater.from(parent.getContext())
.inflate(R.layout.review_card, parent, false),
noGroupsInCommonResId,
groupsInCommonResId,
callbackAdapter);
}
@Override
public void onBindViewHolder(@NonNull ReviewCardViewHolder holder, int position) {
holder.bind(getItem(position));
}
interface Callbacks {
void onCardClicked(@NonNull ReviewCard card);
void onActionClicked(@NonNull ReviewCard card, @NonNull ReviewCard.Action action);
}
private final class CallbacksAdapter implements ReviewCardViewHolder.Callbacks {
private final Callbacks callback;
private CallbacksAdapter(@NonNull Callbacks callback) {
this.callback = callback;
}
@Override
public void onCardClicked(int position) {
callback.onCardClicked(getItem(position));
}
@Override
public void onPrimaryActionItemClicked(int position) {
ReviewCard card = getItem(position);
callback.onActionClicked(card, Objects.requireNonNull(card.getPrimaryAction()));
}
@Override
public void onSecondaryActionItemClicked(int position) {
ReviewCard card = getItem(position);
callback.onActionClicked(card, Objects.requireNonNull(card.getSecondaryAction()));
}
}
}

View File

@@ -0,0 +1,205 @@
package org.thoughtcrime.securesms.profiles.spoofing;
import android.app.AlertDialog;
import android.content.Intent;
import android.os.Bundle;
import android.provider.ContactsContract;
import android.view.View;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.PluralsRes;
import androidx.annotation.StringRes;
import androidx.lifecycle.ViewModelProviders;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.FullScreenDialogFragment;
import org.thoughtcrime.securesms.groups.BadGroupIdException;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment;
public class ReviewCardDialogFragment extends FullScreenDialogFragment {
private static final String EXTRA_TITLE_RES_ID = "extra.title.res.id";
private static final String EXTRA_DESCRIPTION_RES_ID = "extra.description.res.id";
private static final String EXTRA_GROUPS_IN_COMMON_RES_ID = "extra.groups.in.common.res.id";
private static final String EXTRA_NO_GROUPS_IN_COMMON_RES_ID = "extra.no.groups.in.common.res.id";
private static final String EXTRA_RECIPIENT_ID = "extra.recipient.id";
private static final String EXTRA_GROUP_ID = "extra.group.id";
private ReviewCardViewModel viewModel;
public static ReviewCardDialogFragment createForReviewRequest(@NonNull RecipientId recipientId) {
return create(R.string.ReviewCardDialogFragment__review_request,
R.string.ReviewCardDialogFragment__if_youre_not_sure,
R.string.ReviewCardDialogFragment__no_groups_in_common,
R.plurals.ReviewCardDialogFragment__d_groups_in_common,
recipientId,
null);
}
public static ReviewCardDialogFragment createForReviewMembers(@NonNull GroupId.V2 groupId) {
return create(R.string.ReviewCardDialogFragment__review_members,
R.string.ReviewCardDialogFragment__d_group_members_have_the_same_name,
R.string.ReviewCardDialogFragment__no_other_groups_in_common,
R.plurals.ReviewCardDialogFragment__d_other_groups_in_common,
null,
groupId);
}
private static ReviewCardDialogFragment create(@StringRes int titleResId,
@StringRes int descriptionResId,
@StringRes int noGroupsInCommonResId,
@PluralsRes int groupsInCommonResId,
@Nullable RecipientId recipientId,
@Nullable GroupId.V2 groupId)
{
ReviewCardDialogFragment fragment = new ReviewCardDialogFragment();
Bundle args = new Bundle();
args.putInt(EXTRA_TITLE_RES_ID, titleResId);
args.putInt(EXTRA_DESCRIPTION_RES_ID, descriptionResId);
args.putInt(EXTRA_GROUPS_IN_COMMON_RES_ID, groupsInCommonResId);
args.putInt(EXTRA_NO_GROUPS_IN_COMMON_RES_ID, noGroupsInCommonResId);
args.putParcelable(EXTRA_RECIPIENT_ID, recipientId);
args.putString(EXTRA_GROUP_ID, groupId != null ? groupId.toString() : null);
fragment.setArguments(args);
return fragment;
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
try {
initializeViewModel();
} catch (BadGroupIdException e) {
throw new IllegalStateException(e);
}
TextView description = view.findViewById(R.id.description);
RecyclerView recycler = view.findViewById(R.id.recycler);
ReviewCardAdapter adapter = new ReviewCardAdapter(getNoGroupsInCommonResId(), getGroupsInCommonResId(), new AdapterCallbacks());
recycler.setAdapter(adapter);
viewModel.getReviewCards().observe(getViewLifecycleOwner(), cards -> {
adapter.submitList(cards);
description.setText(getString(getDescriptionResId(), cards.size()));
});
viewModel.getReviewEvents().observe(getViewLifecycleOwner(), this::onReviewEvent);
}
private void initializeViewModel() throws BadGroupIdException {
ReviewCardRepository repository = getRepository();
ReviewCardViewModel.Factory factory = new ReviewCardViewModel.Factory(repository, getGroupId() != null);
viewModel = ViewModelProviders.of(this, factory).get(ReviewCardViewModel.class);
}
private @StringRes int getDescriptionResId() {
return requireArguments().getInt(EXTRA_DESCRIPTION_RES_ID);
}
private @PluralsRes int getGroupsInCommonResId() {
return requireArguments().getInt(EXTRA_GROUPS_IN_COMMON_RES_ID);
}
private @StringRes int getNoGroupsInCommonResId() {
return requireArguments().getInt(EXTRA_NO_GROUPS_IN_COMMON_RES_ID);
}
private @Nullable RecipientId getRecipientId() {
return requireArguments().getParcelable(EXTRA_RECIPIENT_ID);
}
private @Nullable GroupId.V2 getGroupId() throws BadGroupIdException {
GroupId groupId = GroupId.parseNullable(requireArguments().getString(EXTRA_GROUP_ID));
if (groupId != null) {
return groupId.requireV2();
} else {
return null;
}
}
private @NonNull ReviewCardRepository getRepository() throws BadGroupIdException {
RecipientId recipientId = getRecipientId();
GroupId.V2 groupId = getGroupId();
if (recipientId != null) {
return new ReviewCardRepository(requireContext(), recipientId);
} else if (groupId != null) {
return new ReviewCardRepository(requireContext(), groupId);
} else {
throw new AssertionError();
}
}
private void onReviewEvent(ReviewCardViewModel.Event reviewEvent) {
switch (reviewEvent) {
case DISMISS:
dismiss();
break;
case REMOVE_FAILED:
toast(R.string.ReviewCardDialogFragment__failed_to_remove_group_member);
break;
default:
throw new IllegalArgumentException("Unhandled event: " + reviewEvent);
}
}
private void toast(@StringRes int message) {
Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show();
}
@Override
protected int getTitle() {
return requireArguments().getInt(EXTRA_TITLE_RES_ID);
}
@Override
protected int getDialogLayoutResource() {
return R.layout.fragment_review;
}
private final class AdapterCallbacks implements ReviewCardAdapter.Callbacks {
@Override
public void onCardClicked(@NonNull ReviewCard card) {
RecipientBottomSheetDialogFragment.create(card.getReviewRecipient().getId(), null)
.show(requireFragmentManager(), null);
}
@Override
public void onActionClicked(@NonNull ReviewCard card, @NonNull ReviewCard.Action action) {
switch (action) {
case UPDATE_CONTACT:
Intent contactEditIntent = new Intent(Intent.ACTION_EDIT);
contactEditIntent.setDataAndType(card.getReviewRecipient().getContactUri(), ContactsContract.Contacts.CONTENT_ITEM_TYPE);
startActivity(contactEditIntent);
break;
case REMOVE_FROM_GROUP:
new AlertDialog.Builder(requireContext())
.setMessage(getString(R.string.ReviewCardDialogFragment__remove_s_from_group,
card.getReviewRecipient().getDisplayName(requireContext())))
.setPositiveButton(R.string.ReviewCardDialogFragment__remove, (dialog, which) -> {
viewModel.act(card, action);
dialog.dismiss();
})
.setNegativeButton(android.R.string.cancel,
(dialog, which) -> dialog.dismiss())
.setCancelable(true)
.show();
break;
default:
viewModel.act(card, action);
}
}
}
}

View File

@@ -0,0 +1,152 @@
package org.thoughtcrime.securesms.profiles.spoofing;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.WorkerThread;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.groups.GroupChangeException;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.groups.GroupManager;
import org.thoughtcrime.securesms.jobs.MultiDeviceMessageRequestResponseJob;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.recipients.RecipientUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import java.io.IOException;
import java.util.List;
import java.util.Objects;
class ReviewCardRepository {
private final Context context;
private final GroupId.V2 groupId;
private final RecipientId recipientId;
protected ReviewCardRepository(@NonNull Context context,
@NonNull GroupId.V2 groupId)
{
this.context = context;
this.groupId = groupId;
this.recipientId = null;
}
protected ReviewCardRepository(@NonNull Context context,
@NonNull RecipientId recipientId)
{
this.context = context;
this.groupId = null;
this.recipientId = recipientId;
}
void loadRecipients(@NonNull OnRecipientsLoadedListener onRecipientsLoadedListener) {
if (groupId != null) {
loadRecipientsForGroup(groupId, onRecipientsLoadedListener);
} else if (recipientId != null) {
loadSimilarRecipients(context, recipientId, onRecipientsLoadedListener);
} else {
throw new AssertionError();
}
}
@WorkerThread
int loadGroupsInCommonCount(@NonNull ReviewRecipient reviewRecipient) {
return ReviewUtil.getGroupsInCommonCount(context, reviewRecipient.getRecipient().getId());
}
void block(@NonNull ReviewCard reviewCard, @NonNull Runnable onActionCompleteListener) {
if (recipientId == null) {
throw new UnsupportedOperationException();
}
SignalExecutors.BOUNDED.execute(() -> {
RecipientUtil.blockNonGroup(context, reviewCard.getReviewRecipient());
onActionCompleteListener.run();
});
}
void delete(@NonNull ReviewCard reviewCard, @NonNull Runnable onActionCompleteListener) {
if (recipientId == null) {
throw new UnsupportedOperationException();
}
SignalExecutors.BOUNDED.execute(() -> {
Recipient resolved = Recipient.resolved(recipientId);
if (resolved.isGroup()) throw new AssertionError();
if (TextSecurePreferences.isMultiDevice(context)) {
ApplicationDependencies.getJobManager().add(MultiDeviceMessageRequestResponseJob.forDelete(recipientId));
}
ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context);
long threadId = Objects.requireNonNull(threadDatabase.getThreadIdFor(recipientId));
threadDatabase.deleteConversation(threadId);
onActionCompleteListener.run();
});
}
void removeFromGroup(@NonNull ReviewCard reviewCard, @NonNull OnRemoveFromGroupListener onRemoveFromGroupListener) {
if (groupId == null) {
throw new UnsupportedOperationException();
}
SignalExecutors.BOUNDED.execute(() -> {
try {
GroupManager.ejectFromGroup(context, groupId, reviewCard.getReviewRecipient());
onRemoveFromGroupListener.onActionCompleted();
} catch (GroupChangeException | IOException e) {
onRemoveFromGroupListener.onActionFailed();
}
});
}
private static void loadRecipientsForGroup(@NonNull GroupId.V2 groupId,
@NonNull OnRecipientsLoadedListener onRecipientsLoadedListener)
{
SignalExecutors.BOUNDED.execute(() -> onRecipientsLoadedListener.onRecipientsLoaded(ReviewUtil.getDuplicatedRecipients(groupId)));
}
private static void loadSimilarRecipients(@NonNull Context context,
@NonNull RecipientId recipientId,
@NonNull OnRecipientsLoadedListener onRecipientsLoadedListener)
{
SignalExecutors.BOUNDED.execute(() -> {
Recipient resolved = Recipient.resolved(recipientId);
List<RecipientId> recipientIds = DatabaseFactory.getRecipientDatabase(context)
.getSimilarRecipientIds(resolved);
if (recipientIds.isEmpty()) {
onRecipientsLoadedListener.onRecipientsLoadFailed();
return;
}
List<ReviewRecipient> recipients = Stream.of(recipientIds)
.map(Recipient::resolved)
.map(ReviewRecipient::new)
.sorted(new ReviewRecipient.Comparator(context, recipientId))
.toList();
onRecipientsLoadedListener.onRecipientsLoaded(recipients);
});
}
interface OnRecipientsLoadedListener {
void onRecipientsLoaded(@NonNull List<ReviewRecipient> recipients);
void onRecipientsLoadFailed();
}
interface OnRemoveFromGroupListener {
void onActionCompleted();
void onActionFailed();
}
}

View File

@@ -0,0 +1,154 @@
package org.thoughtcrime.securesms.profiles.spoofing;
import android.content.Context;
import android.view.View;
import android.widget.Button;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.PluralsRes;
import androidx.annotation.StringRes;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.AvatarImageView;
import org.thoughtcrime.securesms.util.SpanUtil;
class ReviewCardViewHolder extends RecyclerView.ViewHolder {
private final int noGroupsInCommonResId;
private final int groupsInCommonResId;
private final TextView title;
private final AvatarImageView avatar;
private final TextView name;
private final TextView subtextLine1;
private final TextView subtextLine2;
private final Button primaryAction;
private final Button secondaryAction;
public ReviewCardViewHolder(@NonNull View itemView,
@StringRes int noGroupsInCommonResId,
@PluralsRes int groupsInCommonResId,
@NonNull Callbacks callbacks)
{
super(itemView);
this.noGroupsInCommonResId = noGroupsInCommonResId;
this.groupsInCommonResId = groupsInCommonResId;
this.title = itemView.findViewById(R.id.card_title);
this.avatar = itemView.findViewById(R.id.card_avatar);
this.name = itemView.findViewById(R.id.card_name);
this.subtextLine1 = itemView.findViewById(R.id.card_subtext_line1);
this.subtextLine2 = itemView.findViewById(R.id.card_subtext_line2);
this.primaryAction = itemView.findViewById(R.id.card_primary_action_button);
this.secondaryAction = itemView.findViewById(R.id.card_secondary_action_button);
itemView.findViewById(R.id.card_tap_target).setOnClickListener(unused -> {
if (getAdapterPosition() != RecyclerView.NO_POSITION) {
callbacks.onCardClicked(getAdapterPosition());
}
});
primaryAction.setOnClickListener(unused -> {
if (getAdapterPosition() != RecyclerView.NO_POSITION) {
callbacks.onPrimaryActionItemClicked(getAdapterPosition());
}
});
secondaryAction.setOnClickListener(unused -> {
if (getAdapterPosition() != RecyclerView.NO_POSITION) {
callbacks.onSecondaryActionItemClicked(getAdapterPosition());
}
});
}
void bind(@NonNull ReviewCard reviewCard) {
Context context = itemView.getContext();
avatar.setAvatar(reviewCard.getReviewRecipient());
name.setText(reviewCard.getReviewRecipient().getDisplayName(context));
title.setText(getTitleResId(reviewCard.getCardType()));
switch (reviewCard.getCardType()) {
case MEMBER:
case REQUEST:
setNonContactSublines(context, reviewCard);
break;
case YOUR_CONTACT:
subtextLine1.setText(reviewCard.getReviewRecipient().getE164().orNull());
subtextLine2.setText(getGroupsInCommon(reviewCard.getInCommonGroupsCount()));
break;
default:
throw new AssertionError();
}
setActions(reviewCard);
}
private void setNonContactSublines(@NonNull Context context, @NonNull ReviewCard reviewCard) {
subtextLine1.setText(getGroupsInCommon(reviewCard.getInCommonGroupsCount()));
if (reviewCard.getNameChange() != null) {
subtextLine2.setText(SpanUtil.italic(context.getString(R.string.ReviewCard__recently_changed,
reviewCard.getNameChange().getPrevious(),
reviewCard.getNameChange().getNew())));
}
}
private void setActions(@NonNull ReviewCard reviewCard) {
setAction(reviewCard.getPrimaryAction(), primaryAction);
setAction(reviewCard.getSecondaryAction(), secondaryAction);
}
private String getGroupsInCommon(int groupsInCommon) {
if (groupsInCommon == 0) {
return itemView.getContext().getString(noGroupsInCommonResId);
} else {
return itemView.getResources().getQuantityString(groupsInCommonResId, groupsInCommon, groupsInCommon);
}
}
private static void setAction(@Nullable ReviewCard.Action action, @NonNull Button actionButton) {
if (action != null) {
actionButton.setText(getActionLabelResId(action));
actionButton.setVisibility(View.VISIBLE);
} else {
actionButton.setVisibility(View.GONE);
}
}
interface Callbacks {
void onCardClicked(int position);
void onPrimaryActionItemClicked(int position);
void onSecondaryActionItemClicked(int position);
}
private static @StringRes int getTitleResId(@NonNull ReviewCard.CardType cardType) {
switch (cardType) {
case MEMBER:
return R.string.ReviewCard__member;
case REQUEST:
return R.string.ReviewCard__request;
case YOUR_CONTACT:
return R.string.ReviewCard__your_contact;
default:
throw new IllegalArgumentException("Unsupported card type " + cardType);
}
}
private static @StringRes int getActionLabelResId(@NonNull ReviewCard.Action action) {
switch (action) {
case UPDATE_CONTACT:
return R.string.ReviewCard__update_contact;
case DELETE:
return R.string.ReviewCard__delete;
case BLOCK:
return R.string.ReviewCard__block;
case REMOVE_FROM_GROUP:
return R.string.ReviewCard__remove_from_group;
default:
throw new IllegalArgumentException("Unsupported action: " + action);
}
}
}

View File

@@ -0,0 +1,159 @@
package org.thoughtcrime.securesms.profiles.spoofing;
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 com.annimon.stream.Stream;
import org.thoughtcrime.securesms.util.SingleLiveEvent;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
import java.util.List;
import java.util.Objects;
public class ReviewCardViewModel extends ViewModel {
private final ReviewCardRepository repository;
private final boolean isGroupThread;
private final MutableLiveData<List<ReviewRecipient>> reviewRecipients;
private final LiveData<List<ReviewCard>> reviewCards;
private final SingleLiveEvent<Event> reviewEvents;
public ReviewCardViewModel(@NonNull ReviewCardRepository repository, boolean isGroupThread) {
this.repository = repository;
this.isGroupThread = isGroupThread;
this.reviewRecipients = new MutableLiveData<>();
this.reviewCards = LiveDataUtil.mapAsync(reviewRecipients, this::transformReviewRecipients);
this.reviewEvents = new SingleLiveEvent<>();
repository.loadRecipients(new OnRecipientsLoadedListener());
}
LiveData<List<ReviewCard>> getReviewCards() {
return reviewCards;
}
LiveData<Event> getReviewEvents() {
return reviewEvents;
}
public void act(@NonNull ReviewCard card, @NonNull ReviewCard.Action action) {
if (card.getPrimaryAction() == action || card.getSecondaryAction() == action) {
performAction(card, action);
} else {
throw new IllegalArgumentException("Cannot perform " + action + " on review card.");
}
}
private void performAction(@NonNull ReviewCard card, @NonNull ReviewCard.Action action) {
switch (action) {
case BLOCK:
repository.block(card, () -> reviewEvents.postValue(Event.DISMISS));
break;
case DELETE:
repository.delete(card, () -> reviewEvents.postValue(Event.DISMISS));
break;
case REMOVE_FROM_GROUP:
repository.removeFromGroup(card, new OnRemoveFromGroupListener());
break;
default:
throw new IllegalArgumentException("Unsupported action: " + action);
}
}
@WorkerThread
private @NonNull List<ReviewCard> transformReviewRecipients(@NonNull List<ReviewRecipient> reviewRecipients) {
return Stream.of(reviewRecipients)
.map(r -> new ReviewCard(r,
repository.loadGroupsInCommonCount(r) - (isGroupThread ? 1 : 0),
getCardType(r),
getPrimaryAction(r),
getSecondaryAction(r)))
.toList();
}
private @NonNull ReviewCard.CardType getCardType(@NonNull ReviewRecipient reviewRecipient) {
if (reviewRecipient.getRecipient().isSystemContact()) {
return ReviewCard.CardType.YOUR_CONTACT;
} else if (isGroupThread) {
return ReviewCard.CardType.MEMBER;
} else {
return ReviewCard.CardType.REQUEST;
}
}
private @NonNull ReviewCard.Action getPrimaryAction(@NonNull ReviewRecipient reviewRecipient) {
if (reviewRecipient.getRecipient().isSystemContact()) {
return ReviewCard.Action.UPDATE_CONTACT;
} else if (isGroupThread) {
return ReviewCard.Action.REMOVE_FROM_GROUP;
} else {
return ReviewCard.Action.BLOCK;
}
}
private @Nullable ReviewCard.Action getSecondaryAction(@NonNull ReviewRecipient reviewRecipient) {
if (reviewRecipient.getRecipient().isSystemContact()) {
return null;
} else if (isGroupThread) {
return null;
} else {
return ReviewCard.Action.DELETE;
}
}
private class OnRecipientsLoadedListener implements ReviewCardRepository.OnRecipientsLoadedListener {
@Override
public void onRecipientsLoaded(@NonNull List<ReviewRecipient> recipients) {
if (recipients.size() < 2) {
reviewEvents.postValue(Event.DISMISS);
} else {
reviewRecipients.postValue(recipients);
}
}
@Override
public void onRecipientsLoadFailed() {
reviewEvents.postValue(Event.DISMISS);
}
}
private class OnRemoveFromGroupListener implements ReviewCardRepository.OnRemoveFromGroupListener {
@Override
public void onActionCompleted() {
repository.loadRecipients(new OnRecipientsLoadedListener());
}
@Override
public void onActionFailed() {
reviewEvents.postValue(Event.REMOVE_FAILED);
}
}
public static class Factory implements ViewModelProvider.Factory {
private final ReviewCardRepository repository;
private final boolean isGroupThread;
public Factory(@NonNull ReviewCardRepository repository, boolean isGroupThread) {
this.repository = repository;
this.isGroupThread = isGroupThread;
}
@Override
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
return Objects.requireNonNull(modelClass.cast(new ReviewCardViewModel(repository, isGroupThread)));
}
}
public enum Event {
DISMISS,
REMOVE_FAILED
}
}

View File

@@ -0,0 +1,74 @@
package org.thoughtcrime.securesms.profiles.spoofing;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
public class ReviewRecipient {
private final Recipient recipient;
private final ProfileChangeDetails profileChangeDetails;
ReviewRecipient(@NonNull Recipient recipient) {
this(recipient, null);
}
ReviewRecipient(@NonNull Recipient recipient, @Nullable ProfileChangeDetails profileChangeDetails) {
this.recipient = recipient;
this.profileChangeDetails = profileChangeDetails;
}
public @NonNull Recipient getRecipient() {
return recipient;
}
public @Nullable ProfileChangeDetails getProfileChangeDetails() {
return profileChangeDetails;
}
public static class Comparator implements java.util.Comparator<ReviewRecipient> {
private final Context context;
private final RecipientId alwaysFirstId;
public Comparator(@NonNull Context context, @Nullable RecipientId alwaysFirstId) {
this.context = context;
this.alwaysFirstId = alwaysFirstId;
}
@Override
public int compare(ReviewRecipient recipient1, ReviewRecipient recipient2) {
int weight1 = recipient1.getRecipient().getId().equals(alwaysFirstId) ? -100 : 0;
int weight2 = recipient2.getRecipient().getId().equals(alwaysFirstId) ? -100 : 0;
if (recipient1.getProfileChangeDetails() != null && recipient1.getProfileChangeDetails().hasProfileNameChange()) {
weight1--;
}
if (recipient2.getProfileChangeDetails() != null && recipient2.getProfileChangeDetails().hasProfileNameChange()) {
weight2--;
}
if (recipient1.getRecipient().isSystemContact()) {
weight1++;
}
if (recipient2.getRecipient().isSystemContact()) {
weight1++;
}
if (weight1 == weight2) {
return recipient1.getRecipient()
.getDisplayName(context)
.compareTo(recipient2.getRecipient()
.getDisplayName(context));
} else {
return Integer.compare(weight1, weight2);
}
}
}
}

View File

@@ -0,0 +1,116 @@
package org.thoughtcrime.securesms.profiles.spoofing;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.WorkerThread;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.GroupDatabase;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.Base64;
import java.io.IOException;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
public class ReviewUtil {
private static final long TIMEOUT = TimeUnit.HOURS.toMillis(24);
/**
* Checks a single recipient against the database to see whether duplicates exist.
* This should not be used in the context of a group, due to performance reasons.
*
* @param recipientId Id of the recipient we are interested in.
* @return Whether or not multiple recipients share this profile name.
*/
@WorkerThread
public static boolean isRecipientReviewSuggested(@NonNull RecipientId recipientId)
{
Recipient recipient = Recipient.resolved(recipientId);
if (recipient.isGroup() || recipient.isSystemContact()) {
return false;
}
return DatabaseFactory.getRecipientDatabase(ApplicationDependencies.getApplication())
.getSimilarRecipientIds(recipient)
.size() > 1;
}
@WorkerThread
public static @NonNull List<ReviewRecipient> getDuplicatedRecipients(@NonNull GroupId.V2 groupId)
{
Context context = ApplicationDependencies.getApplication();
List<MessageRecord> profileChangeRecords = getProfileChangeRecordsForGroup(context, groupId);
if (profileChangeRecords.isEmpty()) {
return Collections.emptyList();
}
List<Recipient> members = DatabaseFactory.getGroupDatabase(context)
.getGroupMembers(groupId, GroupDatabase.MemberSet.FULL_MEMBERS_INCLUDING_SELF);
List<ReviewRecipient> changed = Stream.of(profileChangeRecords)
.distinctBy(record -> record.getRecipient().getId())
.map(record -> new ReviewRecipient(record.getRecipient().resolve(), getProfileChangeDetails(record)))
.filter(recipient -> !recipient.getRecipient().isSystemContact())
.toList();
List<ReviewRecipient> results = new LinkedList<>();
for (ReviewRecipient recipient : changed) {
if (results.contains(recipient)) {
continue;
}
members.remove(recipient.getRecipient());
for (Recipient member : members) {
if (Objects.equals(member.getDisplayName(context), recipient.getRecipient().getDisplayName(context))) {
results.add(recipient);
results.add(new ReviewRecipient(member));
}
}
}
return results;
}
@WorkerThread
public static @NonNull List<MessageRecord> getProfileChangeRecordsForGroup(@NonNull Context context, @NonNull GroupId.V2 groupId) {
RecipientId recipientId = DatabaseFactory.getRecipientDatabase(context).getByGroupId(groupId).get();
long threadId = Objects.requireNonNull(DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipientId));
return DatabaseFactory.getSmsDatabase(context).getProfileChangeDetailsRecords(threadId, System.currentTimeMillis() - TIMEOUT);
}
@WorkerThread
public static int getGroupsInCommonCount(@NonNull Context context, @NonNull RecipientId recipientId) {
return Stream.of(DatabaseFactory.getGroupDatabase(context)
.getPushGroupsContainingMember(recipientId))
.filter(g -> g.getMembers().contains(Recipient.self().getId()))
.map(GroupDatabase.GroupRecord::getRecipientId)
.toList()
.size();
}
private static @NonNull ProfileChangeDetails getProfileChangeDetails(@NonNull MessageRecord messageRecord) {
try {
return ProfileChangeDetails.parseFrom(Base64.decode(messageRecord.getBody()));
} catch (IOException e) {
throw new IllegalArgumentException(e);
}
}
}