Add basic profile spoofing detection.
This commit is contained in:
parent
2f69a9c38e
commit
3dc1614fbc
30 changed files with 1726 additions and 10 deletions
|
@ -32,7 +32,7 @@ public abstract class FullScreenDialogFragment extends DialogFragment {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@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);
|
View view = inflater.inflate(R.layout.full_screen_dialog_fragment, container, false);
|
||||||
inflater.inflate(getDialogLayoutResource(), view.findViewById(R.id.full_screen_dialog_content), true);
|
inflater.inflate(getDialogLayoutResource(), view.findViewById(R.id.full_screen_dialog_content), true);
|
||||||
toolbar = view.findViewById(R.id.full_screen_dialog_toolbar);
|
toolbar = view.findViewById(R.id.full_screen_dialog_toolbar);
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -58,7 +58,7 @@ public class GeneratedContactPhoto implements FallbackContactPhoto {
|
||||||
return new LayerDrawable(new Drawable[] { base, gradient });
|
return new LayerDrawable(new Drawable[] { base, gradient });
|
||||||
}
|
}
|
||||||
|
|
||||||
return new ResourceContactPhoto(fallbackResId).asDrawable(context, color, inverted);
|
return newFallbackDrawable(context, color, inverted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -66,6 +66,14 @@ public class GeneratedContactPhoto implements FallbackContactPhoto {
|
||||||
return asDrawable(context, color, inverted);
|
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) {
|
private @Nullable String getAbbreviation(String name) {
|
||||||
String[] parts = name.split(" ");
|
String[] parts = name.split(" ");
|
||||||
StringBuilder builder = new StringBuilder();
|
StringBuilder builder = new StringBuilder();
|
||||||
|
|
|
@ -42,6 +42,7 @@ import android.provider.ContactsContract;
|
||||||
import android.text.Editable;
|
import android.text.Editable;
|
||||||
import android.text.Spannable;
|
import android.text.Spannable;
|
||||||
import android.text.SpannableString;
|
import android.text.SpannableString;
|
||||||
|
import android.text.SpannableStringBuilder;
|
||||||
import android.text.TextWatcher;
|
import android.text.TextWatcher;
|
||||||
import android.view.Gravity;
|
import android.view.Gravity;
|
||||||
import android.view.KeyEvent;
|
import android.view.KeyEvent;
|
||||||
|
@ -68,8 +69,10 @@ import androidx.appcompat.app.ActionBar;
|
||||||
import androidx.appcompat.app.AlertDialog;
|
import androidx.appcompat.app.AlertDialog;
|
||||||
import androidx.appcompat.widget.SearchView;
|
import androidx.appcompat.widget.SearchView;
|
||||||
import androidx.appcompat.widget.Toolbar;
|
import androidx.appcompat.widget.Toolbar;
|
||||||
|
import androidx.core.content.ContextCompat;
|
||||||
import androidx.core.content.pm.ShortcutInfoCompat;
|
import androidx.core.content.pm.ShortcutInfoCompat;
|
||||||
import androidx.core.content.pm.ShortcutManagerCompat;
|
import androidx.core.content.pm.ShortcutManagerCompat;
|
||||||
|
import androidx.core.graphics.drawable.DrawableCompat;
|
||||||
import androidx.core.graphics.drawable.IconCompat;
|
import androidx.core.graphics.drawable.IconCompat;
|
||||||
import androidx.lifecycle.ViewModelProviders;
|
import androidx.lifecycle.ViewModelProviders;
|
||||||
|
|
||||||
|
@ -207,6 +210,9 @@ import org.thoughtcrime.securesms.mms.VideoSlide;
|
||||||
import org.thoughtcrime.securesms.notifications.NotificationChannels;
|
import org.thoughtcrime.securesms.notifications.NotificationChannels;
|
||||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||||
import org.thoughtcrime.securesms.profiles.GroupShareProfileView;
|
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.providers.BlobProvider;
|
||||||
import org.thoughtcrime.securesms.reactions.ReactionsBottomSheetDialogFragment;
|
import org.thoughtcrime.securesms.reactions.ReactionsBottomSheetDialogFragment;
|
||||||
import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiBottomSheetDialogFragment;
|
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.PlayStoreUtil;
|
||||||
import org.thoughtcrime.securesms.util.ServiceUtil;
|
import org.thoughtcrime.securesms.util.ServiceUtil;
|
||||||
import org.thoughtcrime.securesms.util.SmsUtil;
|
import org.thoughtcrime.securesms.util.SmsUtil;
|
||||||
|
import org.thoughtcrime.securesms.util.SpanUtil;
|
||||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||||
import org.thoughtcrime.securesms.util.TextSecurePreferences.MediaKeyboardMode;
|
import org.thoughtcrime.securesms.util.TextSecurePreferences.MediaKeyboardMode;
|
||||||
|
import org.thoughtcrime.securesms.util.ThemeUtil;
|
||||||
import org.thoughtcrime.securesms.util.Util;
|
import org.thoughtcrime.securesms.util.Util;
|
||||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||||
import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener;
|
import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener;
|
||||||
|
@ -342,6 +350,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||||
protected Stub<ReminderView> reminderView;
|
protected Stub<ReminderView> reminderView;
|
||||||
private Stub<UnverifiedBannerView> unverifiedBannerView;
|
private Stub<UnverifiedBannerView> unverifiedBannerView;
|
||||||
private Stub<GroupShareProfileView> groupShareProfileView;
|
private Stub<GroupShareProfileView> groupShareProfileView;
|
||||||
|
private Stub<ReviewBannerView> reviewBanner;
|
||||||
private TypingStatusTextWatcher typingTextWatcher;
|
private TypingStatusTextWatcher typingTextWatcher;
|
||||||
private ConversationSearchBottomBar searchNav;
|
private ConversationSearchBottomBar searchNav;
|
||||||
private MenuItem searchViewItem;
|
private MenuItem searchViewItem;
|
||||||
|
@ -1829,6 +1838,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||||
reminderView = ViewUtil.findStubById(this, R.id.reminder_stub);
|
reminderView = ViewUtil.findStubById(this, R.id.reminder_stub);
|
||||||
unverifiedBannerView = ViewUtil.findStubById(this, R.id.unverified_banner_stub);
|
unverifiedBannerView = ViewUtil.findStubById(this, R.id.unverified_banner_stub);
|
||||||
groupShareProfileView = ViewUtil.findStubById(this, R.id.group_share_profile_view_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);
|
quickAttachmentToggle = ViewUtil.findById(this, R.id.quick_attachment_toggle);
|
||||||
inlineAttachmentToggle = ViewUtil.findById(this, R.id.inline_attachment_container);
|
inlineAttachmentToggle = ViewUtil.findById(this, R.id.inline_attachment_container);
|
||||||
inputPanel = ViewUtil.findById(this, R.id.bottom_panel);
|
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);
|
groupViewModel = ViewModelProviders.of(this, new ConversationGroupViewModel.Factory()).get(ConversationGroupViewModel.class);
|
||||||
recipient.observe(this, groupViewModel::onRecipientChange);
|
recipient.observe(this, groupViewModel::onRecipientChange);
|
||||||
groupViewModel.getGroupActiveState().observe(this, unused -> invalidateOptionsMenu());
|
groupViewModel.getGroupActiveState().observe(this, unused -> invalidateOptionsMenu());
|
||||||
|
groupViewModel.getReviewState().observe(this, this::presentGroupReviewBanner);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void initializeMentionsViewModel() {
|
private void initializeMentionsViewModel() {
|
||||||
|
@ -3067,6 +3078,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||||
messageRequestBottomView.setBlockOnClickListener(v -> onMessageRequestBlockClicked(viewModel));
|
messageRequestBottomView.setBlockOnClickListener(v -> onMessageRequestBlockClicked(viewModel));
|
||||||
messageRequestBottomView.setUnblockOnClickListener(v -> onMessageRequestUnblockClicked(viewModel));
|
messageRequestBottomView.setUnblockOnClickListener(v -> onMessageRequestUnblockClicked(viewModel));
|
||||||
|
|
||||||
|
viewModel.getRequestReviewDisplayState().observe(this, this::presentRequestReviewBanner);
|
||||||
viewModel.getMessageData().observe(this, this::presentMessageRequestBottomViewTo);
|
viewModel.getMessageData().observe(this, this::presentMessageRequestBottomViewTo);
|
||||||
viewModel.getMessageRequestDisplayState().observe(this, this::presentMessageRequestDisplayState);
|
viewModel.getMessageRequestDisplayState().observe(this, this::presentMessageRequestDisplayState);
|
||||||
viewModel.getFailures().observe(this, this::showGroupChangeErrorToast);
|
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() {
|
private void showMessageRequestBusy() {
|
||||||
messageRequestBottomView.showBusy();
|
messageRequestBottomView.showBusy();
|
||||||
}
|
}
|
||||||
|
@ -3100,6 +3148,24 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||||
messageRequestBottomView.hideBusy();
|
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) {
|
private void showGroupChangeErrorToast(@NonNull GroupChangeFailureReason e) {
|
||||||
Toast.makeText(this, GroupErrors.getUserDisplayMessage(e), Toast.LENGTH_LONG).show();
|
Toast.makeText(this, GroupErrors.getUserDisplayMessage(e), Toast.LENGTH_LONG).show();
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,14 +10,19 @@ import androidx.lifecycle.Transformations;
|
||||||
import androidx.lifecycle.ViewModel;
|
import androidx.lifecycle.ViewModel;
|
||||||
import androidx.lifecycle.ViewModelProvider;
|
import androidx.lifecycle.ViewModelProvider;
|
||||||
|
|
||||||
|
import com.annimon.stream.Stream;
|
||||||
|
|
||||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||||
import org.thoughtcrime.securesms.database.GroupDatabase;
|
import org.thoughtcrime.securesms.database.GroupDatabase;
|
||||||
import org.thoughtcrime.securesms.database.GroupDatabase.GroupRecord;
|
import org.thoughtcrime.securesms.database.GroupDatabase.GroupRecord;
|
||||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||||
import org.thoughtcrime.securesms.groups.GroupChangeBusyException;
|
import org.thoughtcrime.securesms.groups.GroupChangeBusyException;
|
||||||
import org.thoughtcrime.securesms.groups.GroupChangeFailedException;
|
import org.thoughtcrime.securesms.groups.GroupChangeFailedException;
|
||||||
|
import org.thoughtcrime.securesms.groups.GroupId;
|
||||||
import org.thoughtcrime.securesms.groups.GroupManager;
|
import org.thoughtcrime.securesms.groups.GroupManager;
|
||||||
import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason;
|
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.recipients.Recipient;
|
||||||
import org.thoughtcrime.securesms.util.AsynchronousCallback;
|
import org.thoughtcrime.securesms.util.AsynchronousCallback;
|
||||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
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 org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
final class ConversationGroupViewModel extends ViewModel {
|
final class ConversationGroupViewModel extends ViewModel {
|
||||||
|
|
||||||
|
@ -32,15 +39,31 @@ final class ConversationGroupViewModel extends ViewModel {
|
||||||
private final LiveData<GroupActiveState> groupActiveState;
|
private final LiveData<GroupActiveState> groupActiveState;
|
||||||
private final LiveData<GroupDatabase.MemberLevel> selfMembershipLevel;
|
private final LiveData<GroupDatabase.MemberLevel> selfMembershipLevel;
|
||||||
private final LiveData<Integer> actionableRequestingMembers;
|
private final LiveData<Integer> actionableRequestingMembers;
|
||||||
|
private final LiveData<ReviewState> reviewState;
|
||||||
|
|
||||||
private ConversationGroupViewModel() {
|
private ConversationGroupViewModel() {
|
||||||
this.liveRecipient = new MutableLiveData<>();
|
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.groupActiveState = Transformations.distinctUntilChanged(Transformations.map(groupRecord, ConversationGroupViewModel::mapToGroupActiveState));
|
||||||
this.selfMembershipLevel = Transformations.distinctUntilChanged(Transformations.map(groupRecord, ConversationGroupViewModel::mapToSelfMembershipLevel));
|
this.selfMembershipLevel = Transformations.distinctUntilChanged(Transformations.map(groupRecord, ConversationGroupViewModel::mapToSelfMembershipLevel));
|
||||||
this.actionableRequestingMembers = Transformations.distinctUntilChanged(Transformations.map(groupRecord, ConversationGroupViewModel::mapToActionableRequestingMemberCount));
|
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) {
|
void onRecipientChange(Recipient recipient) {
|
||||||
|
@ -62,6 +85,10 @@ final class ConversationGroupViewModel extends ViewModel {
|
||||||
return selfMembershipLevel;
|
return selfMembershipLevel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public LiveData<ReviewState> getReviewState() {
|
||||||
|
return reviewState;
|
||||||
|
}
|
||||||
|
|
||||||
private static @Nullable GroupRecord getGroupRecordForRecipient(@Nullable Recipient recipient) {
|
private static @Nullable GroupRecord getGroupRecordForRecipient(@Nullable Recipient recipient) {
|
||||||
if (recipient != null && recipient.isGroup()) {
|
if (recipient != null && recipient.isGroup()) {
|
||||||
Application context = ApplicationDependencies.getApplication();
|
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 {
|
static final class GroupActiveState {
|
||||||
private final boolean isActive;
|
private final boolean isActive;
|
||||||
private final boolean isActiveV2;
|
private final boolean isActiveV2;
|
||||||
|
|
|
@ -23,6 +23,7 @@ import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||||
import org.thoughtcrime.securesms.database.model.ReactionRecord;
|
import org.thoughtcrime.securesms.database.model.ReactionRecord;
|
||||||
import org.thoughtcrime.securesms.database.model.SmsMessageRecord;
|
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.database.model.databaseprotos.ReactionList;
|
||||||
import org.thoughtcrime.securesms.insights.InsightsConstants;
|
import org.thoughtcrime.securesms.insights.InsightsConstants;
|
||||||
import org.thoughtcrime.securesms.logging.Log;
|
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 boolean hasReceivedAnyCallsSince(long threadId, long timestamp);
|
||||||
public abstract @Nullable ViewOnceExpirationInfo getNearestExpiringViewOnceMessage();
|
public abstract @Nullable ViewOnceExpirationInfo getNearestExpiringViewOnceMessage();
|
||||||
public abstract boolean isSent(long messageId);
|
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);
|
||||||
public abstract void markExpireStarted(long messageId, long startTime);
|
public abstract void markExpireStarted(long messageId, long startTime);
|
||||||
|
|
|
@ -1582,6 +1582,11 @@ public class MmsDatabase extends MessageDatabase {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<MessageRecord> getProfileChangeDetailsRecords(long threadId, long afterTimestamp) {
|
||||||
|
throw new UnsupportedOperationException();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
void deleteThreads(@NonNull Set<Long> threadIds) {
|
void deleteThreads(@NonNull Set<Long> threadIds) {
|
||||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||||
|
|
|
@ -1633,6 +1633,27 @@ public class RecipientDatabase extends Database {
|
||||||
return updated;
|
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) {
|
public void setProfileName(@NonNull RecipientId id, @NonNull ProfileName profileName) {
|
||||||
ContentValues contentValues = new ContentValues(1);
|
ContentValues contentValues = new ContentValues(1);
|
||||||
contentValues.put(PROFILE_GIVEN_NAME, profileName.getGivenName());
|
contentValues.put(PROFILE_GIVEN_NAME, profileName.getGivenName());
|
||||||
|
|
|
@ -688,6 +688,21 @@ public class SmsDatabase extends MessageDatabase {
|
||||||
return new Pair<>(messageId, threadId);
|
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
|
@Override
|
||||||
public void insertProfileNameChangeMessages(@NonNull Recipient recipient, @NonNull String newProfileName, @NonNull String previousProfileName) {
|
public void insertProfileNameChangeMessages(@NonNull Recipient recipient, @NonNull String newProfileName, @NonNull String previousProfileName) {
|
||||||
ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context);
|
ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context);
|
||||||
|
|
|
@ -20,9 +20,7 @@ public class ResearchMegaphoneDialog extends FullScreenDialogFragment {
|
||||||
private static final String SURVEY_URL = "https://surveys.signalusers.org/s3";
|
private static final String SURVEY_URL = "https://surveys.signalusers.org/s3";
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public @NonNull View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
|
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||||
View view = super.onCreateView(inflater, container, savedInstanceState);
|
|
||||||
|
|
||||||
TextView content = view.findViewById(R.id.research_megaphone_content);
|
TextView content = view.findViewById(R.id.research_megaphone_content);
|
||||||
content.setText(Html.fromHtml(requireContext().getString(R.string.ResearchMegaphoneDialog_we_believe_in_privacy)));
|
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)
|
view.findViewById(R.id.research_megaphone_dialog_no_thanks)
|
||||||
.setOnClickListener(v -> dismissAllowingStateLoss());
|
.setOnClickListener(v -> dismissAllowingStateLoss());
|
||||||
|
|
||||||
return view;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
|
@ -15,6 +15,7 @@ import androidx.lifecycle.ViewModelProvider;
|
||||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||||
import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason;
|
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.LiveRecipient;
|
||||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||||
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver;
|
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.concurrent.SignalExecutors;
|
||||||
import org.thoughtcrime.securesms.util.livedata.LiveDataTriple;
|
import org.thoughtcrime.securesms.util.livedata.LiveDataTriple;
|
||||||
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
|
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
|
||||||
|
import org.whispersystems.libsignal.util.Pair;
|
||||||
|
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
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<List<String>> groups = new MutableLiveData<>(Collections.emptyList());
|
||||||
private final MutableLiveData<GroupMemberCount> memberCount = new MutableLiveData<>(GroupMemberCount.ZERO);
|
private final MutableLiveData<GroupMemberCount> memberCount = new MutableLiveData<>(GroupMemberCount.ZERO);
|
||||||
private final MutableLiveData<DisplayState> displayState = new MutableLiveData<>();
|
private final MutableLiveData<DisplayState> displayState = new MutableLiveData<>();
|
||||||
|
private final LiveData<RequestReviewDisplayState> requestReviewDisplayState;
|
||||||
private final LiveData<RecipientInfo> recipientInfo = Transformations.map(new LiveDataTriple<>(recipient, memberCount, groups),
|
private final LiveData<RecipientInfo> recipientInfo = Transformations.map(new LiveDataTriple<>(recipient, memberCount, groups),
|
||||||
triple -> new RecipientInfo(triple.first(), triple.second(), triple.third()));
|
triple -> new RecipientInfo(triple.first(), triple.second(), triple.third()));
|
||||||
|
|
||||||
|
@ -53,8 +56,10 @@ public class MessageRequestViewModel extends ViewModel {
|
||||||
};
|
};
|
||||||
|
|
||||||
private MessageRequestViewModel(MessageRequestRepository repository) {
|
private MessageRequestViewModel(MessageRequestRepository repository) {
|
||||||
this.repository = repository;
|
this.repository = repository;
|
||||||
this.messageData = LiveDataUtil.mapAsync(recipient, this::createMessageDataForRecipient);
|
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) {
|
public void setConversationInfo(@NonNull RecipientId recipientId, long threadId) {
|
||||||
|
@ -81,6 +86,10 @@ public class MessageRequestViewModel extends ViewModel {
|
||||||
return displayState;
|
return displayState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public LiveData<RequestReviewDisplayState> getRequestReviewDisplayState() {
|
||||||
|
return requestReviewDisplayState;
|
||||||
|
}
|
||||||
|
|
||||||
public LiveData<Recipient> getRecipient() {
|
public LiveData<Recipient> getRecipient() {
|
||||||
return recipient;
|
return recipient;
|
||||||
}
|
}
|
||||||
|
@ -164,6 +173,16 @@ public class MessageRequestViewModel extends ViewModel {
|
||||||
repository.getMemberCount(liveRecipient.getId(), memberCount::postValue);
|
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
|
@WorkerThread
|
||||||
private @NonNull MessageData createMessageDataForRecipient(@NonNull Recipient recipient) {
|
private @NonNull MessageData createMessageDataForRecipient(@NonNull Recipient recipient) {
|
||||||
if (recipient.isBlocked()) {
|
if (recipient.isBlocked()) {
|
||||||
|
@ -280,6 +299,12 @@ public class MessageRequestViewModel extends ViewModel {
|
||||||
INDIVIDUAL
|
INDIVIDUAL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum RequestReviewDisplayState {
|
||||||
|
HIDDEN,
|
||||||
|
SHOWN,
|
||||||
|
NONE
|
||||||
|
}
|
||||||
|
|
||||||
public static final class MessageData {
|
public static final class MessageData {
|
||||||
private final Recipient recipient;
|
private final Recipient recipient;
|
||||||
private final MessageClass messageClass;
|
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 {
|
public static class Factory implements ViewModelProvider.Factory {
|
||||||
|
|
||||||
private final Context context;
|
private final Context context;
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
5
app/src/main/res/drawable/review_card_outline_dark.xml
Normal file
5
app/src/main/res/drawable/review_card_outline_dark.xml
Normal file
|
@ -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>
|
5
app/src/main/res/drawable/review_card_outline_light.xml
Normal file
5
app/src/main/res/drawable/review_card_outline_light.xml
Normal file
|
@ -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>
|
|
@ -42,6 +42,13 @@
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:paddingTop="?attr/actionBarSize">
|
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
|
<ViewStub
|
||||||
android:id="@+id/group_share_profile_view_stub"
|
android:id="@+id/group_share_profile_view_stub"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
|
27
app/src/main/res/layout/fragment_review.xml
Normal file
27
app/src/main/res/layout/fragment_review.xml
Normal file
|
@ -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>
|
95
app/src/main/res/layout/review_banner_view.xml
Normal file
95
app/src/main/res/layout/review_banner_view.xml
Normal file
|
@ -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>
|
121
app/src/main/res/layout/review_card.xml
Normal file
121
app/src/main/res/layout/review_card.xml
Normal file
|
@ -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>
|
|
@ -7,6 +7,8 @@
|
||||||
<attr name="search_view_style" format="reference" />
|
<attr name="search_view_style" format="reference" />
|
||||||
<attr name="search_view_style_dark" 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_primary" format="color"/>
|
||||||
<attr name="title_text_color_secondary" format="color"/>
|
<attr name="title_text_color_secondary" format="color"/>
|
||||||
<attr name="title_text_color_disabled" format="color"/>
|
<attr name="title_text_color_disabled" format="color"/>
|
||||||
|
|
|
@ -160,4 +160,6 @@
|
||||||
<dimen name="group_manage_fragment_row_horizontal_padding">16dp</dimen>
|
<dimen name="group_manage_fragment_row_horizontal_padding">16dp</dimen>
|
||||||
|
|
||||||
<dimen name="wave_form_bar_width">2dp</dimen>
|
<dimen name="wave_form_bar_width">2dp</dimen>
|
||||||
|
|
||||||
|
<dimen name="review_card_icon_arc_radius">14.5dp</dimen>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
@ -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_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_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__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 -->
|
<!-- ConversationListActivity -->
|
||||||
<string name="ConversationListActivity_there_is_no_browser_installed_on_your_device">There is no browser installed on your device.</string>
|
<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 -->
|
<!-- StorageUtil -->
|
||||||
<string name="StorageUtil__s_s">%1$s/%2$s</string>
|
<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 -->
|
<!-- EOF -->
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
@ -163,6 +163,8 @@
|
||||||
<item name="icon_tint">@color/core_grey_75</item>
|
<item name="icon_tint">@color/core_grey_75</item>
|
||||||
<item name="icon_tint_dark">@color/core_grey_15</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="folder_icon">@drawable/ic_folder_outline_24</item>
|
||||||
<item name="backup_enable_dialog_divider_background">@color/core_grey_20</item>
|
<item name="backup_enable_dialog_divider_background">@color/core_grey_20</item>
|
||||||
<item name="backup_enable_subhead_color">@color/core_grey_65</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">@color/core_grey_15</item>
|
||||||
<item name="icon_tint_dark">?icon_tint</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="folder_icon">@drawable/ic_folder_solid_24</item>
|
||||||
<item name="backup_enable_dialog_divider_background">@color/core_grey_60</item>
|
<item name="backup_enable_dialog_divider_background">@color/core_grey_60</item>
|
||||||
<item name="backup_enable_subhead_color">@color/core_grey_25</item>
|
<item name="backup_enable_subhead_color">@color/core_grey_25</item>
|
||||||
|
|
Loading…
Add table
Reference in a new issue