From bbe003a454883aec1ba16d4956006d7d22625686 Mon Sep 17 00:00:00 2001 From: Cody Henthorne Date: Fri, 26 Jun 2020 11:10:54 -0400 Subject: [PATCH] Improve messaging and UX around safety number changes. --- .../securesms/BindableConversationItem.java | 1 + .../securesms/ConfirmIdentityDialog.java | 23 --- .../components/ConversationItemFooter.java | 17 +- .../conversation/ConversationActivity.java | 90 +++++----- .../conversation/ConversationFragment.java | 6 + .../conversation/ConversationItem.java | 5 +- .../ui/error/ChangedRecipient.java | 38 +++++ .../ui/error/SafetyNumberChangeAdapter.java | 85 ++++++++++ .../ui/error/SafetyNumberChangeDialog.java | 157 ++++++++++++++++++ .../error/SafetyNumberChangeRepository.java | 155 +++++++++++++++++ .../ui/error/SafetyNumberChangeViewModel.java | 55 ++++++ .../ui/error/TrustAndVerifyResult.java | 7 + .../securesms/database/MmsSmsDatabase.java | 12 ++ .../database/identity/IdentityRecordList.java | 7 + .../database/model/MessageRecord.java | 4 + .../MessageHeaderViewHolder.java | 6 +- .../util/adapter/AlwaysChangedDiffUtil.java | 16 ++ .../layout/safety_number_change_dialog.xml | 23 +++ .../layout/safety_number_change_recipient.xml | 68 ++++++++ app/src/main/res/values/attrs.xml | 3 + app/src/main/res/values/strings.xml | 12 +- app/src/main/res/values/themes.xml | 6 + 22 files changed, 713 insertions(+), 83 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/ChangedRecipient.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeAdapter.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeDialog.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeRepository.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeViewModel.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/TrustAndVerifyResult.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/util/adapter/AlwaysChangedDiffUtil.java create mode 100644 app/src/main/res/layout/safety_number_change_dialog.xml create mode 100644 app/src/main/res/layout/safety_number_change_recipient.xml diff --git a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java index 38b732ce7f..806d67351a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java @@ -47,5 +47,6 @@ public interface BindableConversationItem extends Unbindable { void onInviteSharedContactClicked(@NonNull List choices); void onReactionClicked(long messageId, boolean isMms); void onGroupMemberAvatarClicked(@NonNull RecipientId recipientId, @NonNull GroupId groupId); + void onMessageWithErrorClicked(@NonNull MessageRecord messageRecord); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ConfirmIdentityDialog.java b/app/src/main/java/org/thoughtcrime/securesms/ConfirmIdentityDialog.java index d32c4a2103..e3d9cc0998 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ConfirmIdentityDialog.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ConfirmIdentityDialog.java @@ -3,7 +3,6 @@ package org.thoughtcrime.securesms; import android.annotation.SuppressLint; import android.content.Context; import android.content.DialogInterface; -import android.database.Cursor; import android.os.AsyncTask; import android.text.SpannableString; import android.text.Spanned; @@ -15,7 +14,6 @@ import androidx.appcompat.app.AlertDialog; import org.thoughtcrime.securesms.crypto.storage.TextSecureIdentityKeyStore; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.MmsDatabase; -import org.thoughtcrime.securesms.database.MmsSmsDatabase; import org.thoughtcrime.securesms.database.PushDatabase; import org.thoughtcrime.securesms.database.SmsDatabase; import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch; @@ -105,7 +103,6 @@ public class ConfirmIdentityDialog extends AlertDialog { } processMessageRecord(messageRecord); - processPendingMessageRecords(messageRecord.getThreadId(), mismatch); return null; } @@ -115,26 +112,6 @@ public class ConfirmIdentityDialog extends AlertDialog { else processIncomingMessageRecord(messageRecord); } - private void processPendingMessageRecords(long threadId, IdentityKeyMismatch mismatch) { - MmsSmsDatabase mmsSmsDatabase = DatabaseFactory.getMmsSmsDatabase(getContext()); - Cursor cursor = mmsSmsDatabase.getIdentityConflictMessagesForThread(threadId); - MmsSmsDatabase.Reader reader = mmsSmsDatabase.readerFor(cursor); - MessageRecord record; - - try { - while ((record = reader.getNext()) != null) { - for (IdentityKeyMismatch recordMismatch : record.getIdentityKeyMismatches()) { - if (mismatch.equals(recordMismatch)) { - processMessageRecord(record); - } - } - } - } finally { - if (reader != null) - reader.close(); - } - } - private void processOutgoingMessageRecord(MessageRecord messageRecord) { SmsDatabase smsDatabase = DatabaseFactory.getSmsDatabase(getContext()); MmsDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(getContext()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java index d451226b44..260d6be6ca 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java @@ -6,14 +6,15 @@ import android.content.Context; import android.content.res.TypedArray; import android.graphics.PorterDuff; import android.os.AsyncTask; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import android.util.AttributeSet; import android.view.View; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.database.DatabaseFactory; @@ -94,9 +95,17 @@ public class ConversationItemFooter extends LinearLayout { private void presentDate(@NonNull MessageRecord messageRecord, @NonNull Locale locale) { dateView.forceLayout(); - if (messageRecord.isFailed()) { - dateView.setText(R.string.ConversationItem_error_not_delivered); + int errorMsg; + if (messageRecord.hasFailedWithNetworkFailures()) { + errorMsg = R.string.ConversationItem_error_network_not_delivered; + } else if (messageRecord.getRecipient().isPushGroup() && messageRecord.isIdentityMismatchFailure()) { + errorMsg = R.string.ConversationItem_error_partially_not_delivered; + } else { + errorMsg = R.string.ConversationItem_error_not_sent_tap_for_details; + } + + dateView.setText(errorMsg); } else if (messageRecord.isPendingInsecureSmsFallback()) { dateView.setText(R.string.ConversationItem_click_to_approve_unencrypted); } else { 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 8021d5a797..4c7ac3a32e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java @@ -108,9 +108,7 @@ import org.thoughtcrime.securesms.components.TooltipPopup; import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider; import org.thoughtcrime.securesms.components.emoji.EmojiStrings; import org.thoughtcrime.securesms.components.emoji.MediaKeyboard; -import org.thoughtcrime.securesms.components.identity.UntrustedSendDialog; import org.thoughtcrime.securesms.components.identity.UnverifiedBannerView; -import org.thoughtcrime.securesms.components.identity.UnverifiedSendDialog; import org.thoughtcrime.securesms.components.location.SignalPlace; import org.thoughtcrime.securesms.components.reminder.ExpiredBuildReminder; import org.thoughtcrime.securesms.components.reminder.Reminder; @@ -125,6 +123,7 @@ import org.thoughtcrime.securesms.contactshare.ContactShareEditActivity; import org.thoughtcrime.securesms.contactshare.ContactUtil; import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher; import org.thoughtcrime.securesms.conversation.ConversationGroupViewModel.GroupActiveState; +import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog; import org.thoughtcrime.securesms.conversationlist.model.MessageResult; import org.thoughtcrime.securesms.crypto.SecurityEvent; import org.thoughtcrime.securesms.database.DatabaseFactory; @@ -173,6 +172,7 @@ import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity; import org.thoughtcrime.securesms.mediasend.Media; import org.thoughtcrime.securesms.mediasend.MediaSendActivity; import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult; +import org.thoughtcrime.securesms.messagedetails.MessageDetailsActivity; import org.thoughtcrime.securesms.messagerequests.MessageRequestViewModel; import org.thoughtcrime.securesms.messagerequests.MessageRequestsBottomView; import org.thoughtcrime.securesms.mms.AttachmentManager; @@ -273,13 +273,16 @@ public class ConversationActivity extends PassphraseRequiredActivity StickerKeyboardProvider.StickerEventListener, AttachmentKeyboard.Callback, ConversationReactionOverlay.OnReactionSelectedListener, - ReactWithAnyEmojiBottomSheetDialogFragment.Callback + ReactWithAnyEmojiBottomSheetDialogFragment.Callback, + SafetyNumberChangeDialog.Callback { private static final int SHORTCUT_ICON_SIZE = Build.VERSION.SDK_INT >= 26 ? ViewUtil.dpToPx(72) : ViewUtil.dpToPx(48 + 16 * 2); private static final String TAG = ConversationActivity.class.getSimpleName(); + public static final String SAFETY_NUMBER_DIALOG = "SAFETY_NUMBER"; + public static final String RECIPIENT_EXTRA = "recipient_id"; public static final String THREAD_ID_EXTRA = "thread_id"; public static final String TEXT_EXTRA = "draft_text"; @@ -1306,50 +1309,28 @@ public class ConversationActivity extends PassphraseRequiredActivity startActivity(intent); } - private void handleUnverifiedRecipients() { - List unverifiedRecipients = identityRecords.getUnverifiedRecipients(); - List unverifiedRecords = identityRecords.getUnverifiedRecords(); - String message = IdentityUtil.getUnverifiedSendDialogDescription(this, unverifiedRecipients); - - if (message == null) return; - - //noinspection CodeBlock2Expr - new UnverifiedSendDialog(this, message, unverifiedRecords, () -> { - initializeIdentityRecords().addListener(new ListenableFuture.Listener() { - @Override - public void onSuccess(Boolean result) { - sendMessage(); - } - - @Override - public void onFailure(ExecutionException e) { - throw new AssertionError(e); - } - }); - }).show(); + private void handleRecentSafetyNumberChange() { + List records = identityRecords.getUnverifiedRecords(); + records.addAll(identityRecords.getUntrustedRecords()); + SafetyNumberChangeDialog.create(records).show(getSupportFragmentManager(), SAFETY_NUMBER_DIALOG); } - private void handleUntrustedRecipients() { - List untrustedRecipients = identityRecords.getUntrustedRecipients(); - List untrustedRecords = identityRecords.getUntrustedRecords(); - String untrustedMessage = IdentityUtil.getUntrustedSendDialogDescription(this, untrustedRecipients); + @Override + public void onSendAnywayAfterSafetyNumberChange() { + initializeIdentityRecords().addListener(new AssertedSuccessListener() { + @Override + public void onSuccess(Boolean result) { + sendMessage(); + } + }); + } - if (untrustedMessage == null) return; - - //noinspection CodeBlock2Expr - new UntrustedSendDialog(this, untrustedMessage, untrustedRecords, () -> { - initializeIdentityRecords().addListener(new ListenableFuture.Listener() { - @Override - public void onSuccess(Boolean result) { - sendMessage(); - } - - @Override - public void onFailure(ExecutionException e) { - throw new AssertionError(e); - } - }); - }).show(); + @Override + public void onMessageResentAfterSafetyNumberChange() { + initializeIdentityRecords().addListener(new AssertedSuccessListener() { + @Override + public void onSuccess(Boolean result) { } + }); } private void handleSecurityChange(boolean isSecureText, boolean isDefaultSms) { @@ -2329,10 +2310,8 @@ public class ConversationActivity extends PassphraseRequiredActivity if ((recipient.isMmsGroup() || recipient.getEmail().isPresent()) && !isMmsEnabled) { handleManualMmsRequired(); - } else if (!forceSms && identityRecords.isUnverified()) { - handleUnverifiedRecipients(); - } else if (!forceSms && identityRecords.isUntrusted()) { - handleUntrustedRecipients(); + } else if (!forceSms && (identityRecords.isUnverified() || identityRecords.isUntrusted())) { + handleRecentSafetyNumberChange(); } else if (isMediaMessage) { sendMediaMessage(forceSms, expiresIn, false, subscriptionId, initiating); } else { @@ -2886,6 +2865,21 @@ public class ConversationActivity extends PassphraseRequiredActivity reactionOverlay.setListVerticalTranslation(translationY); } + @Override + public void onMessageWithErrorClicked(@NonNull MessageRecord messageRecord) { + if (messageRecord.hasFailedWithNetworkFailures()) { + new AlertDialog.Builder(this) + .setMessage(R.string.conversation_activity__message_could_not_be_sent) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.conversation_activity__send, (dialog, which) -> MessageSender.resend(this, messageRecord)) + .show(); + } else if (messageRecord.isIdentityMismatchFailure()) { + SafetyNumberChangeDialog.create(this, messageRecord).show(getSupportFragmentManager(), SAFETY_NUMBER_DIALOG); + } else { + startActivity(MessageDetailsActivity.getIntentForMessageDetails(this, messageRecord, messageRecord.getRecipient().getId(), messageRecord.getThreadId())); + } + } + @Override public void onCursorChanged() { if (!reactionOverlay.isShowing()) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java index 68e543ba67..8774e67e0d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -989,6 +989,7 @@ public class ConversationFragment extends LoggingFragment { @NonNull ConversationReactionOverlay.OnHideListener onHideListener); void onCursorChanged(); void onListVerticalTranslationChanged(float translationY); + void onMessageWithErrorClicked(@NonNull MessageRecord messageRecord); } private class ConversationScrollListener extends OnScrollListener { @@ -1249,6 +1250,11 @@ public class ConversationFragment extends LoggingFragment { RecipientBottomSheetDialogFragment.create(recipientId, groupId).show(requireFragmentManager(), "BOTTOM"); } + + @Override + public void onMessageWithErrorClicked(@NonNull MessageRecord messageRecord) { + listener.onMessageWithErrorClicked(messageRecord); + } } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java index 9a9be3bd1a..d74e08be4a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -90,7 +90,6 @@ import org.thoughtcrime.securesms.jobs.SmsSendJob; import org.thoughtcrime.securesms.linkpreview.LinkPreview; import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil; import org.thoughtcrime.securesms.logging.Log; -import org.thoughtcrime.securesms.messagedetails.MessageDetailsActivity; import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.mms.ImageSlide; import org.thoughtcrime.securesms.mms.PartAuthority; @@ -1375,7 +1374,9 @@ public class ConversationItem extends LinearLayout implements BindableConversati if (!shouldInterceptClicks(messageRecord) && parent != null) { parent.onClick(v); } else if (messageRecord.isFailed()) { - context.startActivity(MessageDetailsActivity.getIntentForMessageDetails(context, messageRecord, conversationRecipient.getId(), messageRecord.getThreadId())); + if (eventListener != null) { + eventListener.onMessageWithErrorClicked(messageRecord); + } } else if (!messageRecord.isOutgoing() && messageRecord.isIdentityMismatchFailure()) { handleApproveIdentity(); } else if (messageRecord.isPendingInsecureSmsFallback()) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/ChangedRecipient.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/ChangedRecipient.java new file mode 100644 index 0000000000..0b474c52f9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/ChangedRecipient.java @@ -0,0 +1,38 @@ +package org.thoughtcrime.securesms.conversation.ui.error; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.database.IdentityDatabase; +import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord; +import org.thoughtcrime.securesms.recipients.Recipient; + +/** + * Wrapper class for helping show a list of recipients that had recent safety number changes. + * + * Also provides helper methods for behavior used in multiple spots. + */ +final class ChangedRecipient { + private final Recipient recipient; + private final IdentityRecord record; + + ChangedRecipient(@NonNull Recipient recipient, @NonNull IdentityRecord record) { + this.recipient = recipient; + this.record = record; + } + + @NonNull Recipient getRecipient() { + return recipient; + } + + @NonNull IdentityRecord getIdentityRecord() { + return record; + } + + boolean isUnverified() { + return record.getVerifiedStatus() == IdentityDatabase.VerifiedStatus.UNVERIFIED; + } + + boolean isVerified() { + return record.getVerifiedStatus() == IdentityDatabase.VerifiedStatus.VERIFIED; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeAdapter.java new file mode 100644 index 0000000000..33afb9b286 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeAdapter.java @@ -0,0 +1,85 @@ +package org.thoughtcrime.securesms.conversation.ui.error; + +import android.graphics.drawable.Drawable; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; +import androidx.recyclerview.widget.ListAdapter; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.AvatarImageView; +import org.thoughtcrime.securesms.components.FromTextView; +import org.thoughtcrime.securesms.database.IdentityDatabase; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.adapter.AlwaysChangedDiffUtil; + +final class SafetyNumberChangeAdapter extends ListAdapter { + + private final Callbacks callbacks; + + SafetyNumberChangeAdapter(@NonNull Callbacks callbacks) { + super(new AlwaysChangedDiffUtil<>()); + this.callbacks = callbacks; + } + + @Override + public @NonNull ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new ViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.safety_number_change_recipient, parent, false)); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + final ChangedRecipient changedRecipient = getItem(position); + holder.bind(changedRecipient); + } + + class ViewHolder extends RecyclerView.ViewHolder { + + final AvatarImageView avatar; + final FromTextView name; + final TextView subtitle; + final View viewButton; + + public ViewHolder(@NonNull View itemView) { + super(itemView); + + avatar = itemView.findViewById(R.id.safety_number_change_recipient_avatar); + name = itemView.findViewById(R.id.safety_number_change_recipient_name); + subtitle = itemView.findViewById(R.id.safety_number_change_recipient_subtitle); + viewButton = itemView.findViewById(R.id.safety_number_change_recipient_view); + } + + void bind(@NonNull ChangedRecipient changedRecipient) { + avatar.setRecipient(changedRecipient.getRecipient()); + name.setText(changedRecipient.getRecipient()); + + if (changedRecipient.isUnverified() || changedRecipient.isVerified()) { + subtitle.setText(R.string.safety_number_change_dialog__previous_verified); + + Drawable check = ContextCompat.getDrawable(itemView.getContext(), R.drawable.check); + if (check != null) { + check.setBounds(0, 0, ViewUtil.dpToPx(12), ViewUtil.dpToPx(12)); + subtitle.setCompoundDrawables(check, null, null, null); + } + } else if (changedRecipient.getRecipient().hasAUserSetDisplayName(itemView.getContext())) { + subtitle.setText(changedRecipient.getRecipient().getE164().or("")); + subtitle.setCompoundDrawables(null, null, null, null); + } else { + subtitle.setText(""); + } + subtitle.setVisibility(TextUtils.isEmpty(subtitle.getText()) ? View.GONE : View.VISIBLE); + + viewButton.setOnClickListener(view -> callbacks.onViewIdentityRecord(changedRecipient.getIdentityRecord())); + } + } + + interface Callbacks { + void onViewIdentityRecord(@NonNull IdentityDatabase.IdentityRecord identityRecord); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeDialog.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeDialog.java new file mode 100644 index 0000000000..2e9a7c3aa5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeDialog.java @@ -0,0 +1,157 @@ +package org.thoughtcrime.securesms.conversation.ui.error; + +import android.app.Activity; +import android.app.Dialog; +import android.content.Context; +import android.content.DialogInterface; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.DialogFragment; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.Observer; +import androidx.lifecycle.ViewModelProviders; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.VerifyIdentityActivity; +import org.thoughtcrime.securesms.database.IdentityDatabase; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.recipients.RecipientId; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public final class SafetyNumberChangeDialog extends DialogFragment implements SafetyNumberChangeAdapter.Callbacks { + + private static final String RECIPIENT_IDS_EXTRA = "recipient_ids"; + private static final String MESSAGE_ID_EXTRA = "message_id"; + + private SafetyNumberChangeViewModel viewModel; + private SafetyNumberChangeAdapter adapter; + private View dialogView; + + public static @NonNull SafetyNumberChangeDialog create(List identityRecords) { + List ids = Stream.of(identityRecords) + .map(record -> record.getRecipientId().serialize()) + .distinct() + .toList(); + + Bundle arguments = new Bundle(); + arguments.putStringArray(RECIPIENT_IDS_EXTRA, ids.toArray(new String[0])); + SafetyNumberChangeDialog fragment = new SafetyNumberChangeDialog(); + fragment.setArguments(arguments); + return fragment; + } + + public static @NonNull SafetyNumberChangeDialog create(Context context, MessageRecord messageRecord) { + List ids = Stream.of(messageRecord.getIdentityKeyMismatches()) + .map(mismatch -> mismatch.getRecipientId(context).serialize()) + .distinct() + .toList(); + + Bundle arguments = new Bundle(); + arguments.putStringArray(RECIPIENT_IDS_EXTRA, ids.toArray(new String[0])); + arguments.putLong(MESSAGE_ID_EXTRA, messageRecord.getId()); + SafetyNumberChangeDialog fragment = new SafetyNumberChangeDialog(); + fragment.setArguments(arguments); + return fragment; + } + + private SafetyNumberChangeDialog() { } + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return dialogView; + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + List recipientIds = Stream.of(getArguments().getStringArray(RECIPIENT_IDS_EXTRA)).map(RecipientId::from).toList(); + long messageId = getArguments().getLong(MESSAGE_ID_EXTRA, -1); + + viewModel = ViewModelProviders.of(this, new SafetyNumberChangeViewModel.Factory(recipientIds, (messageId != -1) ? messageId : null)).get(SafetyNumberChangeViewModel.class); + viewModel.getChangedRecipients().observe(getViewLifecycleOwner(), adapter::submitList); + } + + @Override + public @NonNull Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { + dialogView = LayoutInflater.from(requireActivity()).inflate(R.layout.safety_number_change_dialog, null); + + AlertDialog.Builder builder = new AlertDialog.Builder(requireActivity(), getTheme()); + + configureView(dialogView); + + builder.setTitle(R.string.safety_number_change_dialog__safety_number_changes) + .setView(dialogView) + .setPositiveButton(R.string.safety_number_change_dialog__send_anyway, this::handleSendAnyway) + .setNegativeButton(android.R.string.cancel, null); + + return builder.create(); + } + + @Override public void onDestroyView() { + dialogView = null; + super.onDestroyView(); + } + + private void configureView(View view) { + RecyclerView list = view.findViewById(R.id.safety_number_change_dialog_list); + adapter = new SafetyNumberChangeAdapter(this); + list.setAdapter(adapter); + list.setItemAnimator(null); + list.setLayoutManager(new LinearLayoutManager(requireContext())); + } + + private void handleSendAnyway(DialogInterface dialogInterface, int which) { + Activity activity = getActivity(); + Callback callback; + if (activity instanceof Callback) { + callback = (Callback) activity; + } else { + callback = null; + } + + LiveData trustOrVerifyResultLiveData = viewModel.trustOrVerifyChangedRecipients(); + + Observer observer = new Observer() { + @Override + public void onChanged(TrustAndVerifyResult result) { + if (callback != null) { + switch (result) { + case TRUST_AND_VERIFY: + callback.onSendAnywayAfterSafetyNumberChange(); + break; + case TRUST_VERIFY_AND_RESEND: + callback.onMessageResentAfterSafetyNumberChange(); + break; + } + } + trustOrVerifyResultLiveData.removeObserver(this); + } + }; + + trustOrVerifyResultLiveData.observeForever(observer); + } + + @Override + public void onViewIdentityRecord(@NonNull IdentityDatabase.IdentityRecord identityRecord) { + startActivity(VerifyIdentityActivity.newIntent(requireContext(), identityRecord)); + } + + public interface Callback { + void onSendAnywayAfterSafetyNumberChange(); + void onMessageResentAfterSafetyNumberChange(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeRepository.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeRepository.java new file mode 100644 index 0000000000..7bc012ff14 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeRepository.java @@ -0,0 +1,155 @@ +package org.thoughtcrime.securesms.conversation.ui.error; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.crypto.storage.TextSecureIdentityKeyStore; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.IdentityDatabase; +import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord; +import org.thoughtcrime.securesms.database.MmsDatabase; +import org.thoughtcrime.securesms.database.SmsDatabase; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.sms.MessageSender; +import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; +import org.whispersystems.libsignal.IdentityKey; +import org.whispersystems.libsignal.SignalProtocolAddress; + +import java.util.List; + +import static org.whispersystems.libsignal.SessionCipher.SESSION_LOCK; + +final class SafetyNumberChangeRepository { + + private final Context context; + + SafetyNumberChangeRepository(Context context) { + this.context = context.getApplicationContext(); + } + + @NonNull LiveData getSafetyNumberChangeState(@NonNull List recipientIds, @Nullable Long messageId) { + MutableLiveData liveData = new MutableLiveData<>(); + SignalExecutors.BOUNDED.execute(() -> liveData.postValue(getSafetyNumberChangeStateInternal(recipientIds, messageId))); + return liveData; + } + + @NonNull LiveData trustOrVerifyChangedRecipients(@NonNull List changedRecipients) { + MutableLiveData liveData = new MutableLiveData<>(); + SignalExecutors.BOUNDED.execute(() -> liveData.postValue(trustOrVerifyChangedRecipientsInternal(changedRecipients))); + return liveData; + } + + @NonNull LiveData trustOrVerifyChangedRecipientsAndResend(@NonNull List changedRecipients, @NonNull MessageRecord messageRecord) { + MutableLiveData liveData = new MutableLiveData<>(); + SignalExecutors.BOUNDED.execute(() -> liveData.postValue(trustOrVerifyChangedRecipientsAndResendInternal(changedRecipients, messageRecord))); + return liveData; + } + + @WorkerThread + private @NonNull SafetyNumberChangeState getSafetyNumberChangeStateInternal(@NonNull List recipientIds, @Nullable Long messageId) { + MessageRecord messageRecord = null; + if (messageId != null) { + messageRecord = DatabaseFactory.getMmsSmsDatabase(context).getMessageRecord(messageId); + } + + List recipients = Stream.of(recipientIds).map(Recipient::resolved).toList(); + + List changedRecipients = Stream.of(DatabaseFactory.getIdentityDatabase(context).getIdentities(recipients).getIdentityRecords()) + .map(record -> new ChangedRecipient(Recipient.resolved(record.getRecipientId()), record)) + .toList(); + + return new SafetyNumberChangeState(changedRecipients, messageRecord); + } + + @WorkerThread + private TrustAndVerifyResult trustOrVerifyChangedRecipientsInternal(@NonNull List changedRecipients) { + IdentityDatabase identityDatabase = DatabaseFactory.getIdentityDatabase(context); + + synchronized (SESSION_LOCK) { + for (ChangedRecipient changedRecipient : changedRecipients) { + IdentityRecord identityRecord = changedRecipient.getIdentityRecord(); + + if (changedRecipient.isUnverified()) { + identityDatabase.setVerified(identityRecord.getRecipientId(), + identityRecord.getIdentityKey(), + IdentityDatabase.VerifiedStatus.DEFAULT); + } else { + identityDatabase.setApproval(identityRecord.getRecipientId(), true); + } + } + } + + return TrustAndVerifyResult.TRUST_AND_VERIFY; + } + + @WorkerThread + private TrustAndVerifyResult trustOrVerifyChangedRecipientsAndResendInternal(@NonNull List changedRecipients, + @NonNull MessageRecord messageRecord) { + synchronized (SESSION_LOCK) { + for (ChangedRecipient changedRecipient : changedRecipients) { + SignalProtocolAddress mismatchAddress = new SignalProtocolAddress(changedRecipient.getRecipient().requireServiceId(), 1); + TextSecureIdentityKeyStore identityKeyStore = new TextSecureIdentityKeyStore(context); + identityKeyStore.saveIdentity(mismatchAddress, changedRecipient.getIdentityRecord().getIdentityKey(), true); + } + } + + if (messageRecord.isOutgoing()) { + processOutgoingMessageRecord(changedRecipients, messageRecord); + } + + return TrustAndVerifyResult.TRUST_VERIFY_AND_RESEND; + } + + @WorkerThread + private void processOutgoingMessageRecord(@NonNull List changedRecipients, @NonNull MessageRecord messageRecord) { + SmsDatabase smsDatabase = DatabaseFactory.getSmsDatabase(context); + MmsDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(context); + + for (ChangedRecipient changedRecipient : changedRecipients) { + RecipientId id = changedRecipient.getRecipient().getId(); + IdentityKey identityKey = changedRecipient.getIdentityRecord().getIdentityKey(); + + if (messageRecord.isMms()) { + mmsDatabase.removeMismatchedIdentity(messageRecord.getId(), id, identityKey); + + if (messageRecord.getRecipient().isPushGroup()) { + MessageSender.resendGroupMessage(context, messageRecord, id); + } else { + MessageSender.resend(context, messageRecord); + } + } else { + smsDatabase.removeMismatchedIdentity(messageRecord.getId(), id, identityKey); + + MessageSender.resend(context, messageRecord); + } + } + } + + static final class SafetyNumberChangeState { + + private final List changedRecipients; + private final MessageRecord messageRecord; + + SafetyNumberChangeState(List changedRecipients, @Nullable MessageRecord messageRecord) { + this.changedRecipients = changedRecipients; + this.messageRecord = messageRecord; + } + + @NonNull List getChangedRecipients() { + return changedRecipients; + } + + @Nullable MessageRecord getMessageRecord() { + return messageRecord; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeViewModel.java new file mode 100644 index 0000000000..d43d492aec --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeViewModel.java @@ -0,0 +1,55 @@ +package org.thoughtcrime.securesms.conversation.ui.error; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.Transformations; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeRepository.SafetyNumberChangeState; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.recipients.RecipientId; + +import java.util.List; +import java.util.Objects; + +public final class SafetyNumberChangeViewModel extends ViewModel { + + private final SafetyNumberChangeRepository safetyNumberChangeRepository; + private final LiveData safetyNumberChangeState; + + private SafetyNumberChangeViewModel(@NonNull List recipientIds, @Nullable Long messageId, SafetyNumberChangeRepository safetyNumberChangeRepository) { + this.safetyNumberChangeRepository = safetyNumberChangeRepository; + safetyNumberChangeState = this.safetyNumberChangeRepository.getSafetyNumberChangeState(recipientIds, messageId); + } + + @NonNull LiveData> getChangedRecipients() { + return Transformations.map(safetyNumberChangeState, SafetyNumberChangeState::getChangedRecipients); + } + + @NonNull LiveData trustOrVerifyChangedRecipients() { + SafetyNumberChangeState state = Objects.requireNonNull(safetyNumberChangeState.getValue()); + if (state.getMessageRecord() != null) { + return safetyNumberChangeRepository.trustOrVerifyChangedRecipientsAndResend(state.getChangedRecipients(), state.getMessageRecord()); + } else { + return safetyNumberChangeRepository.trustOrVerifyChangedRecipients(state.getChangedRecipients()); + } + } + + public static final class Factory implements ViewModelProvider.Factory { + private final List recipientIds; + private final Long messageId; + + public Factory(@NonNull List recipientIds, @Nullable Long messageId) { + this.recipientIds = recipientIds; + this.messageId = messageId; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + SafetyNumberChangeRepository repo = new SafetyNumberChangeRepository(ApplicationDependencies.getApplication()); + return Objects.requireNonNull(modelClass.cast(new SafetyNumberChangeViewModel(recipientIds, messageId, repo))); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/TrustAndVerifyResult.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/TrustAndVerifyResult.java new file mode 100644 index 0000000000..37b4c13132 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/TrustAndVerifyResult.java @@ -0,0 +1,7 @@ +package org.thoughtcrime.securesms.conversation.ui.error; + +public enum TrustAndVerifyResult { + TRUST_AND_VERIFY, + TRUST_VERIFY_AND_RESEND, + UNKNOWN +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java index 040837df0d..5d35dd127b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java @@ -280,6 +280,18 @@ public class MmsSmsDatabase extends Database { else return id; } + public @Nullable MessageRecord getMessageRecord(long messageId) { + try { + return DatabaseFactory.getSmsDatabase(context).getMessage(messageId); + } catch (NoSuchMessageException e1) { + try { + return DatabaseFactory.getMmsDatabase(context).getMessageRecord(messageId); + } catch (NoSuchMessageException e2) { + return null; + } + } + } + public void incrementDeliveryReceiptCount(SyncMessageId syncMessageId, long timestamp) { DatabaseFactory.getSmsDatabase(context).incrementReceiptCount(syncMessageId, true); DatabaseFactory.getMmsDatabase(context).incrementReceiptCount(syncMessageId, timestamp, true); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/identity/IdentityRecordList.java b/app/src/main/java/org/thoughtcrime/securesms/database/identity/IdentityRecordList.java index 26a980eb90..e3313e58db 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/identity/IdentityRecordList.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/identity/IdentityRecordList.java @@ -1,12 +1,15 @@ package org.thoughtcrime.securesms.database.identity; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord; import org.thoughtcrime.securesms.database.IdentityDatabase.VerifiedStatus; import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; import java.util.ArrayList; +import java.util.Collections; import java.util.LinkedList; import java.util.List; import java.util.concurrent.TimeUnit; @@ -24,6 +27,10 @@ public final class IdentityRecordList { identityRecords.addAll(identityRecordList.identityRecords); } + public List getIdentityRecords() { + return Collections.unmodifiableList(identityRecords); + } + public boolean isVerified() { for (IdentityRecord identityRecord : identityRecords) { if (identityRecord.getVerifiedStatus() != VerifiedStatus.VERIFIED) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java index 4f76fbd02d..41802f69b0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java @@ -285,6 +285,10 @@ public abstract class MessageRecord extends DisplayRecord { return networkFailures != null && !networkFailures.isEmpty(); } + public boolean hasFailedWithNetworkFailures() { + return isFailed() && ((getRecipient().isPushGroup() && hasNetworkFailures()) || !isIdentityMismatchFailure()); + } + protected SpannableString emphasisAdded(String sequence) { SpannableString spannable = new SpannableString(sequence); spannable.setSpan(new RelativeSizeSpan(0.9f), 0, sequence.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageHeaderViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageHeaderViewHolder.java index 6c5a246fa5..23bfb92e1f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageHeaderViewHolder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageHeaderViewHolder.java @@ -84,11 +84,7 @@ final class MessageHeaderViewHolder extends RecyclerView.ViewHolder { } private void bindErrorState(MessageRecord messageRecord) { - boolean isPushGroup = messageRecord.getRecipient().isPushGroup(); - boolean isGroupNetworkFailure = messageRecord.isFailed() && !messageRecord.getNetworkFailures().isEmpty(); - boolean isIndividualNetworkFailure = messageRecord.isFailed() && !isPushGroup && messageRecord.getIdentityKeyMismatches().isEmpty(); - - if (isGroupNetworkFailure || isIndividualNetworkFailure) { + if (messageRecord.hasFailedWithNetworkFailures()) { errorText.setVisibility(View.VISIBLE); resendButton.setVisibility(View.VISIBLE); resendButton.setOnClickListener(unused -> { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/adapter/AlwaysChangedDiffUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/adapter/AlwaysChangedDiffUtil.java new file mode 100644 index 0000000000..ab58c9cdaf --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/adapter/AlwaysChangedDiffUtil.java @@ -0,0 +1,16 @@ +package org.thoughtcrime.securesms.util.adapter; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.DiffUtil; + +public final class AlwaysChangedDiffUtil extends DiffUtil.ItemCallback { + @Override + public boolean areItemsTheSame(@NonNull T oldItem, @NonNull T newItem) { + return false; + } + + @Override + public boolean areContentsTheSame(@NonNull T oldItem, @NonNull T newItem) { + return false; + } +} diff --git a/app/src/main/res/layout/safety_number_change_dialog.xml b/app/src/main/res/layout/safety_number_change_dialog.xml new file mode 100644 index 0000000000..f144a9228b --- /dev/null +++ b/app/src/main/res/layout/safety_number_change_dialog.xml @@ -0,0 +1,23 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/safety_number_change_recipient.xml b/app/src/main/res/layout/safety_number_change_recipient.xml new file mode 100644 index 0000000000..2ce9225792 --- /dev/null +++ b/app/src/main/res/layout/safety_number_change_recipient.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index ba1a6ac861..535345102b 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -141,6 +141,9 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3898805071..96a9a6d54b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -187,7 +187,9 @@ Selected contact was invalid - Send failed, tap for details + Not sent, tap for details + Partially sent, tap for details + Send failed Received key exchange message, tap to process. %1$s has left the group. Send failed, tap for unsecured fallback @@ -1444,6 +1446,7 @@ Record and send audio attachment Lock recording of audio attachment Enable Signal for SMS + Message could not be sent. Check your connection and try again. Slide to cancel @@ -1479,6 +1482,13 @@ Scroll to the bottom + + Safety Number Changes + Send anyway + The following people may have reinstalled or changed devices. Verify your safety number with them to ensure privacy. + View + Previous verified + Loading countries… Search diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 7851dabed2..2ca95ca3e7 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -258,6 +258,9 @@ @color/white @color/transparent_white_90 + @color/core_grey_05 + @color/core_ultramarine + @drawable/tinted_circle_light @drawable/contact_list_divider_light @@ -546,6 +549,9 @@ @color/transparent_white_20 ?conversation_background + @color/core_grey_75 + @color/core_grey_05 + @drawable/contact_list_divider_dark @color/debuglog_dark_none