From 3dc1614fbcfbea241b179c3c309ceeca5bbc250b Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Wed, 4 Nov 2020 16:00:12 -0400 Subject: [PATCH] Add basic profile spoofing detection. --- .../components/FullScreenDialogFragment.java | 2 +- .../contacts/avatars/FallbackPhoto20dp.java | 62 ++++++ .../avatars/GeneratedContactPhoto.java | 10 +- .../conversation/ConversationActivity.java | 66 ++++++ .../ConversationGroupViewModel.java | 56 ++++- .../securesms/database/MessageDatabase.java | 2 + .../securesms/database/MmsDatabase.java | 5 + .../securesms/database/RecipientDatabase.java | 21 ++ .../securesms/database/SmsDatabase.java | 15 ++ .../megaphone/ResearchMegaphoneDialog.java | 6 +- .../MessageRequestViewModel.java | 39 +++- .../profiles/spoofing/ReviewBannerView.java | 140 ++++++++++++ .../profiles/spoofing/ReviewCard.java | 77 +++++++ .../profiles/spoofing/ReviewCardAdapter.java | 74 +++++++ .../spoofing/ReviewCardDialogFragment.java | 205 ++++++++++++++++++ .../spoofing/ReviewCardRepository.java | 152 +++++++++++++ .../spoofing/ReviewCardViewHolder.java | 154 +++++++++++++ .../spoofing/ReviewCardViewModel.java | 159 ++++++++++++++ .../profiles/spoofing/ReviewRecipient.java | 74 +++++++ .../profiles/spoofing/ReviewUtil.java | 116 ++++++++++ .../res/drawable/review_card_outline_dark.xml | 5 + .../drawable/review_card_outline_light.xml | 5 + .../main/res/layout/conversation_activity.xml | 7 + app/src/main/res/layout/fragment_review.xml | 27 +++ .../main/res/layout/review_banner_view.xml | 95 ++++++++ app/src/main/res/layout/review_card.xml | 121 +++++++++++ app/src/main/res/values/attrs.xml | 2 + app/src/main/res/values/dimens.xml | 2 + app/src/main/res/values/strings.xml | 33 +++ app/src/main/res/values/themes.xml | 4 + 30 files changed, 1726 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/FallbackPhoto20dp.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewBannerView.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCard.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCardAdapter.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCardDialogFragment.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCardRepository.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCardViewHolder.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCardViewModel.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewRecipient.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewUtil.java create mode 100644 app/src/main/res/drawable/review_card_outline_dark.xml create mode 100644 app/src/main/res/drawable/review_card_outline_light.xml create mode 100644 app/src/main/res/layout/fragment_review.xml create mode 100644 app/src/main/res/layout/review_banner_view.xml create mode 100644 app/src/main/res/layout/review_card.xml diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/FullScreenDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/components/FullScreenDialogFragment.java index a2e68a4c8e..c7097e20cd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/FullScreenDialogFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/FullScreenDialogFragment.java @@ -32,7 +32,7 @@ public abstract class FullScreenDialogFragment extends DialogFragment { } @Override - public @NonNull View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + public final @NonNull View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { View view = inflater.inflate(R.layout.full_screen_dialog_fragment, container, false); inflater.inflate(getDialogLayoutResource(), view.findViewById(R.id.full_screen_dialog_content), true); toolbar = view.findViewById(R.id.full_screen_dialog_toolbar); diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/FallbackPhoto20dp.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/FallbackPhoto20dp.java new file mode 100644 index 0000000000..48cd8590da --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/FallbackPhoto20dp.java @@ -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; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/GeneratedContactPhoto.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/GeneratedContactPhoto.java index 86cc7ab5ac..07fd469d58 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/GeneratedContactPhoto.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/GeneratedContactPhoto.java @@ -58,7 +58,7 @@ public class GeneratedContactPhoto implements FallbackContactPhoto { return new LayerDrawable(new Drawable[] { base, gradient }); } - return new ResourceContactPhoto(fallbackResId).asDrawable(context, color, inverted); + return newFallbackDrawable(context, color, inverted); } @Override @@ -66,6 +66,14 @@ public class GeneratedContactPhoto implements FallbackContactPhoto { return asDrawable(context, color, inverted); } + protected @DrawableRes int getFallbackResId() { + return fallbackResId; + } + + protected Drawable newFallbackDrawable(@NonNull Context context, int color, boolean inverted) { + return new ResourceContactPhoto(fallbackResId).asDrawable(context, color, inverted); + } + private @Nullable String getAbbreviation(String name) { String[] parts = name.split(" "); StringBuilder builder = new StringBuilder(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java index b4268a550e..df459b2782 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java @@ -42,6 +42,7 @@ import android.provider.ContactsContract; import android.text.Editable; import android.text.Spannable; import android.text.SpannableString; +import android.text.SpannableStringBuilder; import android.text.TextWatcher; import android.view.Gravity; import android.view.KeyEvent; @@ -68,8 +69,10 @@ import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.widget.SearchView; import androidx.appcompat.widget.Toolbar; +import androidx.core.content.ContextCompat; import androidx.core.content.pm.ShortcutInfoCompat; import androidx.core.content.pm.ShortcutManagerCompat; +import androidx.core.graphics.drawable.DrawableCompat; import androidx.core.graphics.drawable.IconCompat; import androidx.lifecycle.ViewModelProviders; @@ -207,6 +210,9 @@ import org.thoughtcrime.securesms.mms.VideoSlide; import org.thoughtcrime.securesms.notifications.NotificationChannels; import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.profiles.GroupShareProfileView; +import org.thoughtcrime.securesms.profiles.spoofing.ReviewBannerView; +import org.thoughtcrime.securesms.profiles.spoofing.ReviewCardDialogFragment; +import org.thoughtcrime.securesms.profiles.spoofing.ReviewUtil; import org.thoughtcrime.securesms.providers.BlobProvider; import org.thoughtcrime.securesms.reactions.ReactionsBottomSheetDialogFragment; import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiBottomSheetDialogFragment; @@ -244,8 +250,10 @@ import org.thoughtcrime.securesms.util.MessageUtil; import org.thoughtcrime.securesms.util.PlayStoreUtil; import org.thoughtcrime.securesms.util.ServiceUtil; import org.thoughtcrime.securesms.util.SmsUtil; +import org.thoughtcrime.securesms.util.SpanUtil; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.TextSecurePreferences.MediaKeyboardMode; +import org.thoughtcrime.securesms.util.ThemeUtil; import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.ViewUtil; import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener; @@ -342,6 +350,7 @@ public class ConversationActivity extends PassphraseRequiredActivity protected Stub reminderView; private Stub unverifiedBannerView; private Stub groupShareProfileView; + private Stub reviewBanner; private TypingStatusTextWatcher typingTextWatcher; private ConversationSearchBottomBar searchNav; private MenuItem searchViewItem; @@ -1829,6 +1838,7 @@ public class ConversationActivity extends PassphraseRequiredActivity reminderView = ViewUtil.findStubById(this, R.id.reminder_stub); unverifiedBannerView = ViewUtil.findStubById(this, R.id.unverified_banner_stub); groupShareProfileView = ViewUtil.findStubById(this, R.id.group_share_profile_view_stub); + reviewBanner = ViewUtil.findStubById(this, R.id.review_banner_stub); quickAttachmentToggle = ViewUtil.findById(this, R.id.quick_attachment_toggle); inlineAttachmentToggle = ViewUtil.findById(this, R.id.inline_attachment_container); inputPanel = ViewUtil.findById(this, R.id.bottom_panel); @@ -1997,6 +2007,7 @@ public class ConversationActivity extends PassphraseRequiredActivity groupViewModel = ViewModelProviders.of(this, new ConversationGroupViewModel.Factory()).get(ConversationGroupViewModel.class); recipient.observe(this, groupViewModel::onRecipientChange); groupViewModel.getGroupActiveState().observe(this, unused -> invalidateOptionsMenu()); + groupViewModel.getReviewState().observe(this, this::presentGroupReviewBanner); } private void initializeMentionsViewModel() { @@ -3067,6 +3078,7 @@ public class ConversationActivity extends PassphraseRequiredActivity messageRequestBottomView.setBlockOnClickListener(v -> onMessageRequestBlockClicked(viewModel)); messageRequestBottomView.setUnblockOnClickListener(v -> onMessageRequestUnblockClicked(viewModel)); + viewModel.getRequestReviewDisplayState().observe(this, this::presentRequestReviewBanner); viewModel.getMessageData().observe(this, this::presentMessageRequestBottomViewTo); viewModel.getMessageRequestDisplayState().observe(this, this::presentMessageRequestDisplayState); viewModel.getFailures().observe(this, this::showGroupChangeErrorToast); @@ -3092,6 +3104,42 @@ public class ConversationActivity extends PassphraseRequiredActivity }); } + private void presentRequestReviewBanner(@NonNull MessageRequestViewModel.RequestReviewDisplayState state) { + switch (state) { + case SHOWN: + reviewBanner.get().setVisibility(View.VISIBLE); + + CharSequence message = new SpannableStringBuilder().append(SpanUtil.bold(getString(R.string.ConversationFragment__review_requests_carefully))) + .append(" ") + .append(getString(R.string.ConversationFragment__signal_found_another_contact_with_the_same_name)); + + reviewBanner.get().setBannerMessage(message); + + Drawable drawable = Objects.requireNonNull(ThemeUtil.getThemedDrawable(this, R.attr.menu_info_icon)).mutate(); + DrawableCompat.setTint(drawable, ThemeUtil.getThemedColor(this, R.attr.icon_tint)); + + reviewBanner.get().setBannerIcon(drawable); + reviewBanner.get().setOnClickListener(unused -> handleReviewRequest(recipient.getId())); + break; + case HIDDEN: + reviewBanner.get().setVisibility(View.GONE); + break; + default: + break; + } + } + + private void presentGroupReviewBanner(@NonNull ConversationGroupViewModel.ReviewState groupReviewState) { + if (groupReviewState.getCount() > 0) { + reviewBanner.get().setVisibility(View.VISIBLE); + reviewBanner.get().setBannerMessage(getString(R.string.ConversationFragment__d_group_members_have_the_same_name, groupReviewState.getCount())); + reviewBanner.get().setBannerRecipient(groupReviewState.getRecipient()); + reviewBanner.get().setOnClickListener(unused -> handleReviewGroupMembers(groupReviewState.getGroupId())); + } else if (reviewBanner.resolved()) { + reviewBanner.get().setVisibility(View.GONE); + } + } + private void showMessageRequestBusy() { messageRequestBottomView.showBusy(); } @@ -3100,6 +3148,24 @@ public class ConversationActivity extends PassphraseRequiredActivity messageRequestBottomView.hideBusy(); } + private void handleReviewGroupMembers(@Nullable GroupId.V2 groupId) { + if (groupId == null) { + return; + } + + ReviewCardDialogFragment.createForReviewMembers(groupId) + .show(getSupportFragmentManager(), null); + } + + private void handleReviewRequest(@NonNull RecipientId recipientId) { + if (recipientId == Recipient.UNKNOWN.getId()) { + return; + } + + ReviewCardDialogFragment.createForReviewRequest(recipientId) + .show(getSupportFragmentManager(), null); + } + private void showGroupChangeErrorToast(@NonNull GroupChangeFailureReason e) { Toast.makeText(this, GroupErrors.getUserDisplayMessage(e), Toast.LENGTH_LONG).show(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationGroupViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationGroupViewModel.java index 81aa8537bc..0b34efa54d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationGroupViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationGroupViewModel.java @@ -10,14 +10,19 @@ import androidx.lifecycle.Transformations; import androidx.lifecycle.ViewModel; import androidx.lifecycle.ViewModelProvider; +import com.annimon.stream.Stream; + import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.GroupDatabase; import org.thoughtcrime.securesms.database.GroupDatabase.GroupRecord; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.groups.GroupChangeBusyException; import org.thoughtcrime.securesms.groups.GroupChangeFailedException; +import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.groups.GroupManager; import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason; +import org.thoughtcrime.securesms.profiles.spoofing.ReviewRecipient; +import org.thoughtcrime.securesms.profiles.spoofing.ReviewUtil; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.util.AsynchronousCallback; import org.thoughtcrime.securesms.util.FeatureFlags; @@ -25,6 +30,8 @@ import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; import java.io.IOException; +import java.util.Collections; +import java.util.List; final class ConversationGroupViewModel extends ViewModel { @@ -32,15 +39,31 @@ final class ConversationGroupViewModel extends ViewModel { private final LiveData groupActiveState; private final LiveData selfMembershipLevel; private final LiveData actionableRequestingMembers; + private final LiveData reviewState; private ConversationGroupViewModel() { this.liveRecipient = new MutableLiveData<>(); - LiveData groupRecord = LiveDataUtil.mapAsync(liveRecipient, ConversationGroupViewModel::getGroupRecordForRecipient); + LiveData groupRecord = LiveDataUtil.mapAsync(liveRecipient, ConversationGroupViewModel::getGroupRecordForRecipient); + LiveData> duplicates = LiveDataUtil.mapAsync(groupRecord, record -> { + if (record != null && record.isV2Group()) { + return Stream.of(ReviewUtil.getDuplicatedRecipients(record.getId().requireV2())) + .map(ReviewRecipient::getRecipient) + .toList(); + } else { + return Collections.emptyList(); + } + }); this.groupActiveState = Transformations.distinctUntilChanged(Transformations.map(groupRecord, ConversationGroupViewModel::mapToGroupActiveState)); this.selfMembershipLevel = Transformations.distinctUntilChanged(Transformations.map(groupRecord, ConversationGroupViewModel::mapToSelfMembershipLevel)); this.actionableRequestingMembers = Transformations.distinctUntilChanged(Transformations.map(groupRecord, ConversationGroupViewModel::mapToActionableRequestingMemberCount)); + this.reviewState = LiveDataUtil.combineLatest(groupRecord, + duplicates, + (record, dups) -> dups.isEmpty() + ? ReviewState.EMPTY + : new ReviewState(record.getId().requireV2(), dups.get(0), dups.size())); + } void onRecipientChange(Recipient recipient) { @@ -62,6 +85,10 @@ final class ConversationGroupViewModel extends ViewModel { return selfMembershipLevel; } + public LiveData getReviewState() { + return reviewState; + } + private static @Nullable GroupRecord getGroupRecordForRecipient(@Nullable Recipient recipient) { if (recipient != null && recipient.isGroup()) { Application context = ApplicationDependencies.getApplication(); @@ -117,6 +144,33 @@ final class ConversationGroupViewModel extends ViewModel { }); } + static final class ReviewState { + + private static final ReviewState EMPTY = new ReviewState(null, Recipient.UNKNOWN, 0); + + private final GroupId.V2 groupId; + private final Recipient recipient; + private final int count; + + ReviewState(@Nullable GroupId.V2 groupId, @NonNull Recipient recipient, int count) { + this.groupId = groupId; + this.recipient = recipient; + this.count = count; + } + + public @Nullable GroupId.V2 getGroupId() { + return groupId; + } + + public @NonNull Recipient getRecipient() { + return recipient; + } + + public int getCount() { + return count; + } + } + static final class GroupActiveState { private final boolean isActive; private final boolean isActiveV2; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java index cdeb1ecfc5..f5cb6ac9e2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java @@ -23,6 +23,7 @@ import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.ReactionRecord; import org.thoughtcrime.securesms.database.model.SmsMessageRecord; +import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails; import org.thoughtcrime.securesms.database.model.databaseprotos.ReactionList; import org.thoughtcrime.securesms.insights.InsightsConstants; import org.thoughtcrime.securesms.logging.Log; @@ -83,6 +84,7 @@ public abstract class MessageDatabase extends Database implements MmsSmsColumns public abstract boolean hasReceivedAnyCallsSince(long threadId, long timestamp); public abstract @Nullable ViewOnceExpirationInfo getNearestExpiringViewOnceMessage(); public abstract boolean isSent(long messageId); + public abstract List getProfileChangeDetailsRecords(long threadId, long afterTimestamp); public abstract void markExpireStarted(long messageId); public abstract void markExpireStarted(long messageId, long startTime); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java index 6d49fb72f7..4cb12d24be 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java @@ -1582,6 +1582,11 @@ public class MmsDatabase extends MessageDatabase { return false; } + @Override + public List getProfileChangeDetailsRecords(long threadId, long afterTimestamp) { + throw new UnsupportedOperationException(); + } + @Override void deleteThreads(@NonNull Set threadIds) { SQLiteDatabase db = databaseHelper.getWritableDatabase(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java index 26d7709451..5466148c7d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java @@ -1633,6 +1633,27 @@ public class RecipientDatabase extends Database { return updated; } + public @NonNull List 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 results = new ArrayList<>(cursor.getCount()); + while (cursor.moveToNext()) { + results.add(RecipientId.from(CursorUtil.requireLong(cursor, ID))); + } + + return results; + } + } + public void setProfileName(@NonNull RecipientId id, @NonNull ProfileName profileName) { ContentValues contentValues = new ContentValues(1); contentValues.put(PROFILE_GIVEN_NAME, profileName.getGivenName()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java index 1a0ea20ca6..b1382eccf2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -688,6 +688,21 @@ public class SmsDatabase extends MessageDatabase { return new Pair<>(messageId, threadId); } + @Override + public List 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 results = new ArrayList<>(reader.getCount()); + while (reader.getNext() != null) { + results.add(reader.getCurrent()); + } + + return results; + } + } + @Override public void insertProfileNameChangeMessages(@NonNull Recipient recipient, @NonNull String newProfileName, @NonNull String previousProfileName) { ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context); diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/ResearchMegaphoneDialog.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/ResearchMegaphoneDialog.java index 4b190deb8b..b80469fc7f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/megaphone/ResearchMegaphoneDialog.java +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/ResearchMegaphoneDialog.java @@ -20,9 +20,7 @@ public class ResearchMegaphoneDialog extends FullScreenDialogFragment { private static final String SURVEY_URL = "https://surveys.signalusers.org/s3"; @Override - public @NonNull View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - View view = super.onCreateView(inflater, container, savedInstanceState); - + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { TextView content = view.findViewById(R.id.research_megaphone_content); content.setText(Html.fromHtml(requireContext().getString(R.string.ResearchMegaphoneDialog_we_believe_in_privacy))); @@ -31,8 +29,6 @@ public class ResearchMegaphoneDialog extends FullScreenDialogFragment { view.findViewById(R.id.research_megaphone_dialog_no_thanks) .setOnClickListener(v -> dismissAllowingStateLoss()); - - return view; } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestViewModel.java index 60bd9dcb3b..ec1dca4cb4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestViewModel.java @@ -15,6 +15,7 @@ import androidx.lifecycle.ViewModelProvider; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason; +import org.thoughtcrime.securesms.profiles.spoofing.ReviewUtil; import org.thoughtcrime.securesms.recipients.LiveRecipient; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientForeverObserver; @@ -25,6 +26,7 @@ import org.thoughtcrime.securesms.util.SingleLiveEvent; import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; import org.thoughtcrime.securesms.util.livedata.LiveDataTriple; import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; +import org.whispersystems.libsignal.util.Pair; import java.util.Collections; import java.util.List; @@ -38,6 +40,7 @@ public class MessageRequestViewModel extends ViewModel { private final MutableLiveData> groups = new MutableLiveData<>(Collections.emptyList()); private final MutableLiveData memberCount = new MutableLiveData<>(GroupMemberCount.ZERO); private final MutableLiveData displayState = new MutableLiveData<>(); + private final LiveData requestReviewDisplayState; private final LiveData recipientInfo = Transformations.map(new LiveDataTriple<>(recipient, memberCount, groups), triple -> new RecipientInfo(triple.first(), triple.second(), triple.third())); @@ -53,8 +56,10 @@ public class MessageRequestViewModel extends ViewModel { }; private MessageRequestViewModel(MessageRequestRepository repository) { - this.repository = repository; - this.messageData = LiveDataUtil.mapAsync(recipient, this::createMessageDataForRecipient); + this.repository = repository; + this.messageData = LiveDataUtil.mapAsync(recipient, this::createMessageDataForRecipient); + this.requestReviewDisplayState = LiveDataUtil.mapAsync(LiveDataUtil.combineLatest(messageData, displayState, MessageDataDisplayStateHolder::new), + MessageRequestViewModel::transformHolderToReviewDisplayState); } public void setConversationInfo(@NonNull RecipientId recipientId, long threadId) { @@ -81,6 +86,10 @@ public class MessageRequestViewModel extends ViewModel { return displayState; } + public LiveData getRequestReviewDisplayState() { + return requestReviewDisplayState; + } + public LiveData getRecipient() { return recipient; } @@ -164,6 +173,16 @@ public class MessageRequestViewModel extends ViewModel { repository.getMemberCount(liveRecipient.getId(), memberCount::postValue); } + private static RequestReviewDisplayState transformHolderToReviewDisplayState(@NonNull MessageDataDisplayStateHolder holder) { + if (holder.messageData.messageClass == MessageClass.INDIVIDUAL && holder.displayState == DisplayState.DISPLAY_MESSAGE_REQUEST) { + return ReviewUtil.isRecipientReviewSuggested(holder.messageData.getRecipient().getId()) + ? RequestReviewDisplayState.SHOWN + : RequestReviewDisplayState.HIDDEN; + } else { + return RequestReviewDisplayState.NONE; + } + } + @WorkerThread private @NonNull MessageData createMessageDataForRecipient(@NonNull Recipient recipient) { if (recipient.isBlocked()) { @@ -280,6 +299,12 @@ public class MessageRequestViewModel extends ViewModel { INDIVIDUAL } + public enum RequestReviewDisplayState { + HIDDEN, + SHOWN, + NONE + } + public static final class MessageData { private final Recipient recipient; private final MessageClass messageClass; @@ -298,6 +323,16 @@ public class MessageRequestViewModel extends ViewModel { } } + private static final class MessageDataDisplayStateHolder { + private final MessageData messageData; + private final DisplayState displayState; + + private MessageDataDisplayStateHolder(@NonNull MessageData messageData, @NonNull DisplayState displayState) { + this.messageData = messageData; + this.displayState = displayState; + } + } + public static class Factory implements ViewModelProvider.Factory { private final Context context; diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewBannerView.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewBannerView.java new file mode 100644 index 0000000000..db94720f02 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewBannerView.java @@ -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); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCard.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCard.java new file mode 100644 index 0000000000..db662259c8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCard.java @@ -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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCardAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCardAdapter.java new file mode 100644 index 0000000000..5178deb4eb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCardAdapter.java @@ -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 { + + 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())); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCardDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCardDialogFragment.java new file mode 100644 index 0000000000..fc21e07f7b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCardDialogFragment.java @@ -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); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCardRepository.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCardRepository.java new file mode 100644 index 0000000000..9d6aa37bbc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCardRepository.java @@ -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 recipientIds = DatabaseFactory.getRecipientDatabase(context) + .getSimilarRecipientIds(resolved); + + if (recipientIds.isEmpty()) { + onRecipientsLoadedListener.onRecipientsLoadFailed(); + return; + } + + List 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 recipients); + void onRecipientsLoadFailed(); + } + + interface OnRemoveFromGroupListener { + void onActionCompleted(); + void onActionFailed(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCardViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCardViewHolder.java new file mode 100644 index 0000000000..54b9fc70da --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCardViewHolder.java @@ -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); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCardViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCardViewModel.java new file mode 100644 index 0000000000..26e1aa0246 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCardViewModel.java @@ -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> reviewRecipients; + private final LiveData> reviewCards; + private final SingleLiveEvent 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> getReviewCards() { + return reviewCards; + } + + LiveData 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 transformReviewRecipients(@NonNull List 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 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 create(@NonNull Class modelClass) { + return Objects.requireNonNull(modelClass.cast(new ReviewCardViewModel(repository, isGroupThread))); + } + } + + public enum Event { + DISMISS, + REMOVE_FAILED + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewRecipient.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewRecipient.java new file mode 100644 index 0000000000..ee1f0c6193 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewRecipient.java @@ -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 { + + 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); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewUtil.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewUtil.java new file mode 100644 index 0000000000..e8dc66612e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewUtil.java @@ -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 getDuplicatedRecipients(@NonNull GroupId.V2 groupId) + { + Context context = ApplicationDependencies.getApplication(); + List profileChangeRecords = getProfileChangeRecordsForGroup(context, groupId); + + if (profileChangeRecords.isEmpty()) { + return Collections.emptyList(); + } + + List members = DatabaseFactory.getGroupDatabase(context) + .getGroupMembers(groupId, GroupDatabase.MemberSet.FULL_MEMBERS_INCLUDING_SELF); + + List 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 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 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); + } + } +} diff --git a/app/src/main/res/drawable/review_card_outline_dark.xml b/app/src/main/res/drawable/review_card_outline_dark.xml new file mode 100644 index 0000000000..ac88995590 --- /dev/null +++ b/app/src/main/res/drawable/review_card_outline_dark.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/review_card_outline_light.xml b/app/src/main/res/drawable/review_card_outline_light.xml new file mode 100644 index 0000000000..84c7aac12f --- /dev/null +++ b/app/src/main/res/drawable/review_card_outline_light.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/conversation_activity.xml b/app/src/main/res/layout/conversation_activity.xml index 28cffbec8e..6d125da2d4 100644 --- a/app/src/main/res/layout/conversation_activity.xml +++ b/app/src/main/res/layout/conversation_activity.xml @@ -42,6 +42,13 @@ android:orientation="vertical" android:paddingTop="?attr/actionBarSize"> + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/review_banner_view.xml b/app/src/main/res/layout/review_banner_view.xml new file mode 100644 index 0000000000..704f793329 --- /dev/null +++ b/app/src/main/res/layout/review_banner_view.xml @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/review_card.xml b/app/src/main/res/layout/review_card.xml new file mode 100644 index 0000000000..243d3aaaaf --- /dev/null +++ b/app/src/main/res/layout/review_card.xml @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + +