Add basic profile spoofing detection.

fork-5.53.8
Alex Hart 2020-11-04 16:00:12 -04:00 zatwierdzone przez Alan Evans
rodzic 2f69a9c38e
commit 3dc1614fbc
30 zmienionych plików z 1726 dodań i 10 usunięć

Wyświetl plik

@ -32,7 +32,7 @@ public abstract class FullScreenDialogFragment extends DialogFragment {
}
@Override
public @NonNull View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
public final @NonNull View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.full_screen_dialog_fragment, container, false);
inflater.inflate(getDialogLayoutResource(), view.findViewById(R.id.full_screen_dialog_content), true);
toolbar = view.findViewById(R.id.full_screen_dialog_toolbar);

Wyświetl plik

@ -0,0 +1,62 @@
package org.thoughtcrime.securesms.contacts.avatars;
import android.content.Context;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.LayerDrawable;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.core.graphics.drawable.DrawableCompat;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.ThemeUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
import java.util.Objects;
/**
* Fallback resource based contact photo with a 20dp icon
*/
public final class FallbackPhoto20dp implements FallbackContactPhoto {
@DrawableRes private final int drawable20dp;
public FallbackPhoto20dp(@DrawableRes int drawable20dp) {
this.drawable20dp = drawable20dp;
}
@Override
public Drawable asDrawable(Context context, int color) {
return buildDrawable(context, color);
}
@Override
public Drawable asDrawable(Context context, int color, boolean inverted) {
return buildDrawable(context, color);
}
@Override
public Drawable asSmallDrawable(Context context, int color, boolean inverted) {
return buildDrawable(context, color);
}
@Override
public Drawable asCallCard(Context context) {
throw new UnsupportedOperationException();
}
private @NonNull Drawable buildDrawable(@NonNull Context context, int color) {
Drawable background = DrawableCompat.wrap(Objects.requireNonNull(AppCompatResources.getDrawable(context, R.drawable.circle_tintable))).mutate();
Drawable foreground = AppCompatResources.getDrawable(context, drawable20dp);
Drawable gradient = ThemeUtil.getThemedDrawable(context, R.attr.resource_placeholder_gradient);
LayerDrawable drawable = new LayerDrawable(new Drawable[]{background, foreground, gradient});
int foregroundInset = ViewUtil.dpToPx(2);
DrawableCompat.setTint(background, color);
drawable.setLayerInset(1, foregroundInset, foregroundInset, foregroundInset, foregroundInset);
return drawable;
}
}

Wyświetl plik

@ -58,7 +58,7 @@ public class GeneratedContactPhoto implements FallbackContactPhoto {
return new LayerDrawable(new Drawable[] { base, gradient });
}
return new ResourceContactPhoto(fallbackResId).asDrawable(context, color, inverted);
return newFallbackDrawable(context, color, inverted);
}
@Override
@ -66,6 +66,14 @@ public class GeneratedContactPhoto implements FallbackContactPhoto {
return asDrawable(context, color, inverted);
}
protected @DrawableRes int getFallbackResId() {
return fallbackResId;
}
protected Drawable newFallbackDrawable(@NonNull Context context, int color, boolean inverted) {
return new ResourceContactPhoto(fallbackResId).asDrawable(context, color, inverted);
}
private @Nullable String getAbbreviation(String name) {
String[] parts = name.split(" ");
StringBuilder builder = new StringBuilder();

Wyświetl plik

@ -42,6 +42,7 @@ import android.provider.ContactsContract;
import android.text.Editable;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.SpannableStringBuilder;
import android.text.TextWatcher;
import android.view.Gravity;
import android.view.KeyEvent;
@ -68,8 +69,10 @@ import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.widget.SearchView;
import androidx.appcompat.widget.Toolbar;
import androidx.core.content.ContextCompat;
import androidx.core.content.pm.ShortcutInfoCompat;
import androidx.core.content.pm.ShortcutManagerCompat;
import androidx.core.graphics.drawable.DrawableCompat;
import androidx.core.graphics.drawable.IconCompat;
import androidx.lifecycle.ViewModelProviders;
@ -207,6 +210,9 @@ import org.thoughtcrime.securesms.mms.VideoSlide;
import org.thoughtcrime.securesms.notifications.NotificationChannels;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.profiles.GroupShareProfileView;
import org.thoughtcrime.securesms.profiles.spoofing.ReviewBannerView;
import org.thoughtcrime.securesms.profiles.spoofing.ReviewCardDialogFragment;
import org.thoughtcrime.securesms.profiles.spoofing.ReviewUtil;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.reactions.ReactionsBottomSheetDialogFragment;
import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiBottomSheetDialogFragment;
@ -244,8 +250,10 @@ import org.thoughtcrime.securesms.util.MessageUtil;
import org.thoughtcrime.securesms.util.PlayStoreUtil;
import org.thoughtcrime.securesms.util.ServiceUtil;
import org.thoughtcrime.securesms.util.SmsUtil;
import org.thoughtcrime.securesms.util.SpanUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.TextSecurePreferences.MediaKeyboardMode;
import org.thoughtcrime.securesms.util.ThemeUtil;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener;
@ -342,6 +350,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
protected Stub<ReminderView> reminderView;
private Stub<UnverifiedBannerView> unverifiedBannerView;
private Stub<GroupShareProfileView> groupShareProfileView;
private Stub<ReviewBannerView> reviewBanner;
private TypingStatusTextWatcher typingTextWatcher;
private ConversationSearchBottomBar searchNav;
private MenuItem searchViewItem;
@ -1829,6 +1838,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
reminderView = ViewUtil.findStubById(this, R.id.reminder_stub);
unverifiedBannerView = ViewUtil.findStubById(this, R.id.unverified_banner_stub);
groupShareProfileView = ViewUtil.findStubById(this, R.id.group_share_profile_view_stub);
reviewBanner = ViewUtil.findStubById(this, R.id.review_banner_stub);
quickAttachmentToggle = ViewUtil.findById(this, R.id.quick_attachment_toggle);
inlineAttachmentToggle = ViewUtil.findById(this, R.id.inline_attachment_container);
inputPanel = ViewUtil.findById(this, R.id.bottom_panel);
@ -1997,6 +2007,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
groupViewModel = ViewModelProviders.of(this, new ConversationGroupViewModel.Factory()).get(ConversationGroupViewModel.class);
recipient.observe(this, groupViewModel::onRecipientChange);
groupViewModel.getGroupActiveState().observe(this, unused -> invalidateOptionsMenu());
groupViewModel.getReviewState().observe(this, this::presentGroupReviewBanner);
}
private void initializeMentionsViewModel() {
@ -3067,6 +3078,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
messageRequestBottomView.setBlockOnClickListener(v -> onMessageRequestBlockClicked(viewModel));
messageRequestBottomView.setUnblockOnClickListener(v -> onMessageRequestUnblockClicked(viewModel));
viewModel.getRequestReviewDisplayState().observe(this, this::presentRequestReviewBanner);
viewModel.getMessageData().observe(this, this::presentMessageRequestBottomViewTo);
viewModel.getMessageRequestDisplayState().observe(this, this::presentMessageRequestDisplayState);
viewModel.getFailures().observe(this, this::showGroupChangeErrorToast);
@ -3092,6 +3104,42 @@ public class ConversationActivity extends PassphraseRequiredActivity
});
}
private void presentRequestReviewBanner(@NonNull MessageRequestViewModel.RequestReviewDisplayState state) {
switch (state) {
case SHOWN:
reviewBanner.get().setVisibility(View.VISIBLE);
CharSequence message = new SpannableStringBuilder().append(SpanUtil.bold(getString(R.string.ConversationFragment__review_requests_carefully)))
.append(" ")
.append(getString(R.string.ConversationFragment__signal_found_another_contact_with_the_same_name));
reviewBanner.get().setBannerMessage(message);
Drawable drawable = Objects.requireNonNull(ThemeUtil.getThemedDrawable(this, R.attr.menu_info_icon)).mutate();
DrawableCompat.setTint(drawable, ThemeUtil.getThemedColor(this, R.attr.icon_tint));
reviewBanner.get().setBannerIcon(drawable);
reviewBanner.get().setOnClickListener(unused -> handleReviewRequest(recipient.getId()));
break;
case HIDDEN:
reviewBanner.get().setVisibility(View.GONE);
break;
default:
break;
}
}
private void presentGroupReviewBanner(@NonNull ConversationGroupViewModel.ReviewState groupReviewState) {
if (groupReviewState.getCount() > 0) {
reviewBanner.get().setVisibility(View.VISIBLE);
reviewBanner.get().setBannerMessage(getString(R.string.ConversationFragment__d_group_members_have_the_same_name, groupReviewState.getCount()));
reviewBanner.get().setBannerRecipient(groupReviewState.getRecipient());
reviewBanner.get().setOnClickListener(unused -> handleReviewGroupMembers(groupReviewState.getGroupId()));
} else if (reviewBanner.resolved()) {
reviewBanner.get().setVisibility(View.GONE);
}
}
private void showMessageRequestBusy() {
messageRequestBottomView.showBusy();
}
@ -3100,6 +3148,24 @@ public class ConversationActivity extends PassphraseRequiredActivity
messageRequestBottomView.hideBusy();
}
private void handleReviewGroupMembers(@Nullable GroupId.V2 groupId) {
if (groupId == null) {
return;
}
ReviewCardDialogFragment.createForReviewMembers(groupId)
.show(getSupportFragmentManager(), null);
}
private void handleReviewRequest(@NonNull RecipientId recipientId) {
if (recipientId == Recipient.UNKNOWN.getId()) {
return;
}
ReviewCardDialogFragment.createForReviewRequest(recipientId)
.show(getSupportFragmentManager(), null);
}
private void showGroupChangeErrorToast(@NonNull GroupChangeFailureReason e) {
Toast.makeText(this, GroupErrors.getUserDisplayMessage(e), Toast.LENGTH_LONG).show();
}

Wyświetl plik

@ -10,14 +10,19 @@ import androidx.lifecycle.Transformations;
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.GroupDatabase;
import org.thoughtcrime.securesms.database.GroupDatabase.GroupRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.groups.GroupChangeBusyException;
import org.thoughtcrime.securesms.groups.GroupChangeFailedException;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.groups.GroupManager;
import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason;
import org.thoughtcrime.securesms.profiles.spoofing.ReviewRecipient;
import org.thoughtcrime.securesms.profiles.spoofing.ReviewUtil;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.AsynchronousCallback;
import org.thoughtcrime.securesms.util.FeatureFlags;
@ -25,6 +30,8 @@ import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
final class ConversationGroupViewModel extends ViewModel {
@ -32,15 +39,31 @@ final class ConversationGroupViewModel extends ViewModel {
private final LiveData<GroupActiveState> groupActiveState;
private final LiveData<GroupDatabase.MemberLevel> selfMembershipLevel;
private final LiveData<Integer> actionableRequestingMembers;
private final LiveData<ReviewState> reviewState;
private ConversationGroupViewModel() {
this.liveRecipient = new MutableLiveData<>();
LiveData<GroupRecord> groupRecord = LiveDataUtil.mapAsync(liveRecipient, ConversationGroupViewModel::getGroupRecordForRecipient);
LiveData<GroupRecord> groupRecord = LiveDataUtil.mapAsync(liveRecipient, ConversationGroupViewModel::getGroupRecordForRecipient);
LiveData<List<Recipient>> duplicates = LiveDataUtil.mapAsync(groupRecord, record -> {
if (record != null && record.isV2Group()) {
return Stream.of(ReviewUtil.getDuplicatedRecipients(record.getId().requireV2()))
.map(ReviewRecipient::getRecipient)
.toList();
} else {
return Collections.emptyList();
}
});
this.groupActiveState = Transformations.distinctUntilChanged(Transformations.map(groupRecord, ConversationGroupViewModel::mapToGroupActiveState));
this.selfMembershipLevel = Transformations.distinctUntilChanged(Transformations.map(groupRecord, ConversationGroupViewModel::mapToSelfMembershipLevel));
this.actionableRequestingMembers = Transformations.distinctUntilChanged(Transformations.map(groupRecord, ConversationGroupViewModel::mapToActionableRequestingMemberCount));
this.reviewState = LiveDataUtil.combineLatest(groupRecord,
duplicates,
(record, dups) -> dups.isEmpty()
? ReviewState.EMPTY
: new ReviewState(record.getId().requireV2(), dups.get(0), dups.size()));
}
void onRecipientChange(Recipient recipient) {
@ -62,6 +85,10 @@ final class ConversationGroupViewModel extends ViewModel {
return selfMembershipLevel;
}
public LiveData<ReviewState> getReviewState() {
return reviewState;
}
private static @Nullable GroupRecord getGroupRecordForRecipient(@Nullable Recipient recipient) {
if (recipient != null && recipient.isGroup()) {
Application context = ApplicationDependencies.getApplication();
@ -117,6 +144,33 @@ final class ConversationGroupViewModel extends ViewModel {
});
}
static final class ReviewState {
private static final ReviewState EMPTY = new ReviewState(null, Recipient.UNKNOWN, 0);
private final GroupId.V2 groupId;
private final Recipient recipient;
private final int count;
ReviewState(@Nullable GroupId.V2 groupId, @NonNull Recipient recipient, int count) {
this.groupId = groupId;
this.recipient = recipient;
this.count = count;
}
public @Nullable GroupId.V2 getGroupId() {
return groupId;
}
public @NonNull Recipient getRecipient() {
return recipient;
}
public int getCount() {
return count;
}
}
static final class GroupActiveState {
private final boolean isActive;
private final boolean isActiveV2;

Wyświetl plik

@ -23,6 +23,7 @@ import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.database.model.SmsMessageRecord;
import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails;
import org.thoughtcrime.securesms.database.model.databaseprotos.ReactionList;
import org.thoughtcrime.securesms.insights.InsightsConstants;
import org.thoughtcrime.securesms.logging.Log;
@ -83,6 +84,7 @@ public abstract class MessageDatabase extends Database implements MmsSmsColumns
public abstract boolean hasReceivedAnyCallsSince(long threadId, long timestamp);
public abstract @Nullable ViewOnceExpirationInfo getNearestExpiringViewOnceMessage();
public abstract boolean isSent(long messageId);
public abstract List<MessageRecord> getProfileChangeDetailsRecords(long threadId, long afterTimestamp);
public abstract void markExpireStarted(long messageId);
public abstract void markExpireStarted(long messageId, long startTime);

Wyświetl plik

@ -1582,6 +1582,11 @@ public class MmsDatabase extends MessageDatabase {
return false;
}
@Override
public List<MessageRecord> getProfileChangeDetailsRecords(long threadId, long afterTimestamp) {
throw new UnsupportedOperationException();
}
@Override
void deleteThreads(@NonNull Set<Long> threadIds) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();

Wyświetl plik

@ -1633,6 +1633,27 @@ public class RecipientDatabase extends Database {
return updated;
}
public @NonNull List<RecipientId> getSimilarRecipientIds(@NonNull Recipient recipient) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
String[] projection = SqlUtil.buildArgs(ID, "COALESCE(" + nullIfEmpty(SYSTEM_DISPLAY_NAME) + ", " + nullIfEmpty(PROFILE_JOINED_NAME) + ") AS checked_name");
String where = "checked_name = ?";
String[] arguments = SqlUtil.buildArgs(recipient.getProfileName().toString());
try (Cursor cursor = db.query(TABLE_NAME, projection, where, arguments, null, null, null)) {
if (cursor == null || cursor.getCount() == 0) {
return Collections.emptyList();
}
List<RecipientId> results = new ArrayList<>(cursor.getCount());
while (cursor.moveToNext()) {
results.add(RecipientId.from(CursorUtil.requireLong(cursor, ID)));
}
return results;
}
}
public void setProfileName(@NonNull RecipientId id, @NonNull ProfileName profileName) {
ContentValues contentValues = new ContentValues(1);
contentValues.put(PROFILE_GIVEN_NAME, profileName.getGivenName());

Wyświetl plik

@ -688,6 +688,21 @@ public class SmsDatabase extends MessageDatabase {
return new Pair<>(messageId, threadId);
}
@Override
public List<MessageRecord> getProfileChangeDetailsRecords(long threadId, long afterTimestamp) {
String where = THREAD_ID + " = ? AND " + DATE_RECEIVED + " >= ? AND " + TYPE + " = ?";
String[] args = SqlUtil.buildArgs(threadId, afterTimestamp, Types.PROFILE_CHANGE_TYPE);
try (Reader reader = readerFor(queryMessages(where, args, true, -1))) {
List<MessageRecord> results = new ArrayList<>(reader.getCount());
while (reader.getNext() != null) {
results.add(reader.getCurrent());
}
return results;
}
}
@Override
public void insertProfileNameChangeMessages(@NonNull Recipient recipient, @NonNull String newProfileName, @NonNull String previousProfileName) {
ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context);

Wyświetl plik

@ -20,9 +20,7 @@ public class ResearchMegaphoneDialog extends FullScreenDialogFragment {
private static final String SURVEY_URL = "https://surveys.signalusers.org/s3";
@Override
public @NonNull View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
View view = super.onCreateView(inflater, container, savedInstanceState);
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
TextView content = view.findViewById(R.id.research_megaphone_content);
content.setText(Html.fromHtml(requireContext().getString(R.string.ResearchMegaphoneDialog_we_believe_in_privacy)));
@ -31,8 +29,6 @@ public class ResearchMegaphoneDialog extends FullScreenDialogFragment {
view.findViewById(R.id.research_megaphone_dialog_no_thanks)
.setOnClickListener(v -> dismissAllowingStateLoss());
return view;
}
@Override

Wyświetl plik

@ -15,6 +15,7 @@ import androidx.lifecycle.ViewModelProvider;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
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;
@ -25,6 +26,7 @@ import org.thoughtcrime.securesms.util.SingleLiveEvent;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import org.thoughtcrime.securesms.util.livedata.LiveDataTriple;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
import org.whispersystems.libsignal.util.Pair;
import java.util.Collections;
import java.util.List;
@ -38,6 +40,7 @@ public class MessageRequestViewModel extends ViewModel {
private final MutableLiveData<List<String>> groups = new MutableLiveData<>(Collections.emptyList());
private final MutableLiveData<GroupMemberCount> memberCount = new MutableLiveData<>(GroupMemberCount.ZERO);
private final MutableLiveData<DisplayState> displayState = new MutableLiveData<>();
private final LiveData<RequestReviewDisplayState> requestReviewDisplayState;
private final LiveData<RecipientInfo> recipientInfo = Transformations.map(new LiveDataTriple<>(recipient, memberCount, groups),
triple -> new RecipientInfo(triple.first(), triple.second(), triple.third()));
@ -53,8 +56,10 @@ public class MessageRequestViewModel extends ViewModel {
};
private MessageRequestViewModel(MessageRequestRepository repository) {
this.repository = repository;
this.messageData = LiveDataUtil.mapAsync(recipient, this::createMessageDataForRecipient);
this.repository = repository;
this.messageData = LiveDataUtil.mapAsync(recipient, this::createMessageDataForRecipient);
this.requestReviewDisplayState = LiveDataUtil.mapAsync(LiveDataUtil.combineLatest(messageData, displayState, MessageDataDisplayStateHolder::new),
MessageRequestViewModel::transformHolderToReviewDisplayState);
}
public void setConversationInfo(@NonNull RecipientId recipientId, long threadId) {
@ -81,6 +86,10 @@ public class MessageRequestViewModel extends ViewModel {
return displayState;
}
public LiveData<RequestReviewDisplayState> getRequestReviewDisplayState() {
return requestReviewDisplayState;
}
public LiveData<Recipient> getRecipient() {
return recipient;
}
@ -164,6 +173,16 @@ public class MessageRequestViewModel extends ViewModel {
repository.getMemberCount(liveRecipient.getId(), memberCount::postValue);
}
private static RequestReviewDisplayState transformHolderToReviewDisplayState(@NonNull MessageDataDisplayStateHolder holder) {
if (holder.messageData.messageClass == MessageClass.INDIVIDUAL && holder.displayState == DisplayState.DISPLAY_MESSAGE_REQUEST) {
return ReviewUtil.isRecipientReviewSuggested(holder.messageData.getRecipient().getId())
? RequestReviewDisplayState.SHOWN
: RequestReviewDisplayState.HIDDEN;
} else {
return RequestReviewDisplayState.NONE;
}
}
@WorkerThread
private @NonNull MessageData createMessageDataForRecipient(@NonNull Recipient recipient) {
if (recipient.isBlocked()) {
@ -280,6 +299,12 @@ public class MessageRequestViewModel extends ViewModel {
INDIVIDUAL
}
public enum RequestReviewDisplayState {
HIDDEN,
SHOWN,
NONE
}
public static final class MessageData {
private final Recipient recipient;
private final MessageClass messageClass;
@ -298,6 +323,16 @@ public class MessageRequestViewModel extends ViewModel {
}
}
private static final class MessageDataDisplayStateHolder {
private final MessageData messageData;
private final DisplayState displayState;
private MessageDataDisplayStateHolder(@NonNull MessageData messageData, @NonNull DisplayState displayState) {
this.messageData = messageData;
this.displayState = displayState;
}
}
public static class Factory implements ViewModelProvider.Factory {
private final Context context;

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape android:shape="rectangle" xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="10dp" />
<stroke android:color="@color/core_grey_80" android:width="1dp" />
</shape>

Wyświetl plik

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape android:shape="rectangle" xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="10dp" />
<stroke android:color="@color/core_grey_15" android:width="1dp" />
</shape>

Wyświetl plik

@ -42,6 +42,13 @@
android:orientation="vertical"
android:paddingTop="?attr/actionBarSize">
<ViewStub
android:id="@+id/review_banner_stub"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inflatedId="@+id/review_banner"
android:layout="@layout/review_banner_view" />
<ViewStub
android:id="@+id/group_share_profile_view_stub"
android:layout_width="match_parent"

Wyświetl plik

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<TextView
android:id="@+id/description"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:textAppearance="@style/Signal.Text.Caption"
tools:text="@string/ReviewCardDialogFragment__d_group_members_have_the_same_name" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/review_card" />
</LinearLayout>

Wyświetl plik

@ -0,0 +1,95 @@
<?xml version="1.0" encoding="utf-8"?>
<org.thoughtcrime.securesms.profiles.spoofing.ReviewBannerView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/review_banner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="108dp">
<FrameLayout
android:id="@+id/banner_icon_frame"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<ImageView
android:id="@+id/banner_icon"
android:layout_width="40dp"
android:layout_height="40dp"
android:scaleType="centerInside" />
<org.thoughtcrime.securesms.components.AvatarImageView
android:id="@+id/banner_avatar_1"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_gravity="top|start"
android:layout_marginStart="2dp"
android:layout_marginTop="2dp"
android:visibility="gone"
tools:visibility="visible" />
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/banner_avatar_stroke"
android:layout_width="29dp"
android:layout_height="29dp"
android:layout_marginStart="11.5dp"
android:layout_marginTop="11.5dp"
android:background="@drawable/circle_tintable"
android:visibility="gone"
app:backgroundTint="?android:windowBackground"
tools:backgroundTint="@color/red"
tools:visibility="visible" />
<org.thoughtcrime.securesms.components.AvatarImageView
android:id="@+id/banner_avatar_2"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_gravity="end|bottom"
android:layout_marginEnd="2dp"
android:layout_marginBottom="2dp"
android:visibility="gone"
tools:visibility="visible" />
</FrameLayout>
<TextView
android:id="@+id/banner_message"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="72dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="72dp"
android:textAppearance="@style/Signal.Text.Body"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Review requests carefully Signal found another contact with the same name." />
<ImageView
android:id="@+id/banner_close"
android:layout_width="48dp"
android:layout_height="48dp"
android:scaleType="centerInside"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_x_20" />
<TextView
android:id="@+id/banner_tap_to_review"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginBottom="16dp"
android:text="@string/ConversationFragment__tap_to_review"
android:textAppearance="@style/TextAppearance.Signal.Body1.Bold"
android:textColor="?colorAccent"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="@id/banner_message"
app:layout_constraintTop_toBottomOf="@id/banner_message" />
</org.thoughtcrime.securesms.profiles.spoofing.ReviewBannerView>

Wyświetl plik

@ -0,0 +1,121 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:background="?attr/review_card_bg"
android:minHeight="190dp">
<View
android:id="@+id/card_tap_target"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:minHeight="52dp"
app:layout_constraintBottom_toBottomOf="@id/card_subtext_line2"
app:layout_constraintEnd_toEndOf="@id/card_name"
app:layout_constraintStart_toStartOf="@id/card_avatar"
app:layout_constraintTop_toTopOf="@id/card_avatar" />
<TextView
android:id="@+id/card_title"
android:layout_width="match_parent"
android:layout_height="40dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:gravity="center_vertical"
android:textAppearance="@style/TextAppearance.Signal.Body2.Bold"
android:textColor="?attr/title_text_color_secondary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Member" />
<org.thoughtcrime.securesms.components.AvatarImageView
android:id="@+id/card_avatar"
android:layout_width="52dp"
android:layout_height="52dp"
android:layout_marginStart="16dp"
android:layout_marginTop="12dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/card_title"
tools:src="@drawable/ic_person_large" />
<TextView
android:id="@+id/card_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginEnd="16dp"
android:textAppearance="@style/TextAppearance.Signal.Body1.Bold"
app:layout_constraintBottom_toTopOf="@id/card_subtext_line1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/card_avatar"
app:layout_constraintTop_toTopOf="@id/card_avatar"
app:layout_constraintVertical_bias="0.0"
tools:text="Michelle Tyler" />
<TextView
android:id="@+id/card_subtext_line1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginEnd="16dp"
android:textAppearance="@style/TextAppearance.AppCompat.Body2"
android:textColor="?attr/title_text_color_secondary"
app:layout_constraintBottom_toTopOf="@id/card_subtext_line2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/card_avatar"
app:layout_constraintTop_toBottomOf="@id/card_name"
tools:text="Line 1 sample text." />
<TextView
android:id="@+id/card_subtext_line2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginEnd="16dp"
android:textAppearance="@style/TextAppearance.AppCompat.Body2"
android:textColor="?attr/title_text_color_secondary"
app:layout_constraintBottom_toBottomOf="@id/card_avatar"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/card_avatar"
app:layout_constraintTop_toBottomOf="@id/card_subtext_line1"
tools:text="Line 2 can be multiple lines and should gracefully handle being broken up." />
<Button
android:id="@+id/card_primary_action_button"
style="@style/Button.Borderless"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:layout_marginEnd="4dp"
android:layout_marginBottom="6dp"
android:paddingStart="12dp"
android:paddingEnd="12dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/card_subtext_line2"
app:layout_constraintVertical_bias="1.0"
tools:text="@string/ReviewCard__block" />
<Button
android:id="@+id/card_secondary_action_button"
style="@style/Button.Borderless"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:layout_marginBottom="6dp"
android:paddingStart="12dp"
android:paddingEnd="12dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/card_primary_action_button"
app:layout_constraintTop_toBottomOf="@id/card_subtext_line2"
app:layout_constraintVertical_bias="1.0"
tools:text="@string/ReviewCard__delete" />
</androidx.constraintlayout.widget.ConstraintLayout>

Wyświetl plik

@ -7,6 +7,8 @@
<attr name="search_view_style" format="reference" />
<attr name="search_view_style_dark" format="reference" />
<attr name="review_card_bg" format="reference" />
<attr name="title_text_color_primary" format="color"/>
<attr name="title_text_color_secondary" format="color"/>
<attr name="title_text_color_disabled" format="color"/>

Wyświetl plik

@ -160,4 +160,6 @@
<dimen name="group_manage_fragment_row_horizontal_padding">16dp</dimen>
<dimen name="wave_form_bar_width">2dp</dimen>
<dimen name="review_card_icon_arc_radius">14.5dp</dimen>
</resources>

Wyświetl plik

@ -337,6 +337,10 @@
<string name="ConversationFragment_outgoing_view_once_media_files_are_automatically_removed">Outgoing view-once media files are automatically removed after they are sent</string>
<string name="ConversationFragment_you_already_viewed_this_message">You already viewed this message</string>
<string name="ConversationFragment__you_can_add_notes_for_yourself_in_this_conversation">You can add notes for yourself in this conversation.\nIf your account has any linked devices, new notes will be synced.</string>
<string name="ConversationFragment__d_group_members_have_the_same_name">%1$d group members have the same name.</string>
<string name="ConversationFragment__tap_to_review">Tap to review</string>
<string name="ConversationFragment__review_requests_carefully">Review requests carefully</string>
<string name="ConversationFragment__signal_found_another_contact_with_the_same_name">Signal found another contact with the same name.</string>
<!-- ConversationListActivity -->
<string name="ConversationListActivity_there_is_no_browser_installed_on_your_device">There is no browser installed on your device.</string>
@ -2788,6 +2792,35 @@
<!-- StorageUtil -->
<string name="StorageUtil__s_s">%1$s/%2$s</string>
<!-- ReviewCardDialogFragment -->
<string name="ReviewCardDialogFragment__review_members">Review Members</string>
<string name="ReviewCardDialogFragment__review_request">Review Request</string>
<string name="ReviewCardDialogFragment__d_group_members_have_the_same_name">%1$d group members have the same name, review the members below and choose to take action.</string>
<string name="ReviewCardDialogFragment__if_youre_not_sure">If you\'re not sure who the request is from, review the contacts below and take action.</string>
<string name="ReviewCardDialogFragment__no_other_groups_in_common">No other groups in common.</string>
<string name="ReviewCardDialogFragment__no_groups_in_common">No groups in common.</string>
<plurals name="ReviewCardDialogFragment__d_other_groups_in_common">
<item quantity="one">%d group in common</item>
<item quantity="other">%d groups in common</item>
</plurals>
<plurals name="ReviewCardDialogFragment__d_groups_in_common">
<item quantity="one">%d group in common</item>
<item quantity="other">%d groups in common</item>
</plurals>
<string name="ReviewCardDialogFragment__remove_s_from_group">Remove %1$s from group?</string>
<string name="ReviewCardDialogFragment__remove">Remove</string>
<string name="ReviewCardDialogFragment__failed_to_remove_group_member">Failed to remove group member.</string>
<!-- ReviewCard -->
<string name="ReviewCard__member">Member</string>
<string name="ReviewCard__request">Request</string>
<string name="ReviewCard__your_contact">Your contact</string>
<string name="ReviewCard__remove_from_group">Remove from group</string>
<string name="ReviewCard__update_contact">Update contact</string>
<string name="ReviewCard__block">Block</string>
<string name="ReviewCard__delete">Delete</string>
<string name="ReviewCard__recently_changed">Recently changed their profile name from %1$s to %2$s</string>
<!-- EOF -->
</resources>

Wyświetl plik

@ -163,6 +163,8 @@
<item name="icon_tint">@color/core_grey_75</item>
<item name="icon_tint_dark">@color/core_grey_15</item>
<item name="review_card_bg">@drawable/review_card_outline_light</item>
<item name="folder_icon">@drawable/ic_folder_outline_24</item>
<item name="backup_enable_dialog_divider_background">@color/core_grey_20</item>
<item name="backup_enable_subhead_color">@color/core_grey_65</item>
@ -505,6 +507,8 @@
<item name="icon_tint">@color/core_grey_15</item>
<item name="icon_tint_dark">?icon_tint</item>
<item name="review_card_bg">@drawable/review_card_outline_dark</item>
<item name="folder_icon">@drawable/ic_folder_solid_24</item>
<item name="backup_enable_dialog_divider_background">@color/core_grey_60</item>
<item name="backup_enable_subhead_color">@color/core_grey_25</item>