Add support for displaying both ACI and e164 safety numbers.

This commit is contained in:
Clark
2023-07-19 10:17:45 -04:00
committed by Nicholas
parent 00bbb6bc6e
commit 461875b0e4
29 changed files with 1633 additions and 848 deletions

View File

@@ -549,7 +549,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
icon = DSLSettingsIcon.from(R.drawable.ic_safety_number_24),
isEnabled = !state.isDeprecatedOrUnregistered,
onClick = {
startActivity(VerifyIdentityActivity.newIntent(requireActivity(), recipientState.identityRecord))
VerifyIdentityActivity.startOrShowExchangeMessagesDialog(requireActivity(), recipientState.identityRecord)
}
)
}

View File

@@ -0,0 +1,229 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.verify
import android.animation.ValueAnimator
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Outline
import android.graphics.PorterDuff
import android.graphics.drawable.BitmapDrawable
import android.util.AttributeSet
import android.view.View
import android.view.ViewOutlineProvider
import android.view.animation.Animation
import android.view.animation.AnticipateInterpolator
import android.view.animation.ScaleAnimation
import android.widget.ImageView
import android.widget.TextSwitcher
import android.widget.TextView
import androidx.annotation.DrawableRes
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat
import androidx.core.widget.ImageViewCompat
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
import org.signal.core.util.dp
import org.signal.libsignal.protocol.fingerprint.Fingerprint
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.qr.QrCodeUtil
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.visible
import java.nio.charset.Charset
import java.util.Locale
class SafetyNumberQrView : ConstraintLayout {
companion object {
private const val NUMBER_OF_SEGMENTS = 12
@JvmStatic
fun getSegments(fingerprint: Fingerprint): Array<String> {
val segments = arrayOfNulls<String>(NUMBER_OF_SEGMENTS)
val digits = fingerprint.displayableFingerprint.displayText
val partSize = digits.length / NUMBER_OF_SEGMENTS
for (i in 0 until NUMBER_OF_SEGMENTS) {
segments[i] = digits.substring(i * partSize, i * partSize + partSize)
}
return (0 until NUMBER_OF_SEGMENTS).map { digits.substring(it * partSize, it * partSize + partSize) }.toTypedArray()
}
}
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet, defaultStyle: Int) : super(context, attrs, defaultStyle)
private val codes: Array<TextView>
val numbersContainer: View
val qrCodeContainer: View
val shareButton: ImageView
private val loading: View
private val qrCode: ImageView
private val qrVerified: ImageView
private val tapLabel: TextSwitcher
init {
inflate(context, R.layout.safety_number_qr_view, this)
numbersContainer = findViewById(R.id.number_table)
loading = findViewById(R.id.loading)
qrCodeContainer = findViewById(R.id.qr_code_container)
qrCode = findViewById(R.id.qr_code)
qrVerified = findViewById(R.id.qr_verified)
tapLabel = findViewById(R.id.tap_label)
codes = arrayOf(
findViewById(R.id.code_first),
findViewById(R.id.code_second),
findViewById(R.id.code_third),
findViewById(R.id.code_fourth),
findViewById(R.id.code_fifth),
findViewById(R.id.code_sixth),
findViewById(R.id.code_seventh),
findViewById(R.id.code_eighth),
findViewById(R.id.code_ninth),
findViewById(R.id.code_tenth),
findViewById(R.id.code_eleventh),
findViewById(R.id.code_twelth)
)
shareButton = findViewById(R.id.share)
outlineProvider = object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
outline.setRoundRect(0, 0, view.width, view.height, 24.dp.toFloat())
}
}
clipToOutline = true
setSafetyNumberType(false)
}
fun setFingerprintViews(fingerprint: Fingerprint, animate: Boolean) {
val segments: Array<String> = getSegments(fingerprint)
for (i in codes.indices) {
if (animate) setCodeSegment(codes[i], segments[i]) else codes[i].text = segments[i]
}
val qrCodeData = fingerprint.scannableFingerprint.serialized
val qrCodeString = String(qrCodeData, Charset.forName("ISO-8859-1"))
val qrCodeBitmap = QrCodeUtil.create(qrCodeString)
qrCode.setImageBitmap(qrCodeBitmap)
shareButton.visible = true
if (animate) {
ViewUtil.fadeIn(qrCode, 1000)
ViewUtil.fadeIn(tapLabel, 1000)
ViewUtil.fadeOut(loading, 300, GONE)
} else {
qrCode.visibility = VISIBLE
tapLabel.visibility = VISIBLE
loading.visibility = GONE
}
}
fun setSafetyNumberType(newType: Boolean) {
if (newType) {
ImageViewCompat.setImageTintList(shareButton, ColorStateList.valueOf(ContextCompat.getColor(context, R.color.signal_dark_colorOnSurface)))
setBackgroundColor(ContextCompat.getColor(context, R.color.safety_number_card_blue))
codes.forEach {
it.setTextColor(ContextCompat.getColor(context, R.color.signal_light_colorOnPrimary))
}
} else {
ImageViewCompat.setImageTintList(shareButton, ColorStateList.valueOf(ContextCompat.getColor(context, R.color.signal_light_colorOnSurface)))
setBackgroundColor(ContextCompat.getColor(context, R.color.safety_number_card_grey))
codes.forEach {
it.setTextColor(ContextCompat.getColor(context, R.color.signal_light_colorOnSurfaceVariant))
}
}
}
fun animateVerifiedSuccess() {
val qrBitmap = (qrCode.drawable as BitmapDrawable).bitmap
val qrSuccess: Bitmap = createVerifiedBitmap(qrBitmap.width, qrBitmap.height, R.drawable.ic_check_white_48dp)
qrVerified.setImageBitmap(qrSuccess)
qrVerified.background.setColorFilter(resources.getColor(R.color.green_500), PorterDuff.Mode.MULTIPLY)
tapLabel.setText(context.getString(R.string.verify_display_fragment__successful_match))
animateVerified()
}
fun animateVerifiedFailure() {
val qrBitmap = (qrCode.drawable as BitmapDrawable).bitmap
val qrSuccess: Bitmap = createVerifiedBitmap(qrBitmap.width, qrBitmap.height, R.drawable.ic_close_white_48dp)
qrVerified.setImageBitmap(qrSuccess)
qrVerified.background.setColorFilter(resources.getColor(R.color.red_500), PorterDuff.Mode.MULTIPLY)
tapLabel.setText(context.getString(R.string.verify_display_fragment__failed_to_verify_safety_number))
animateVerified()
}
private fun animateVerified() {
val scaleAnimation = ScaleAnimation(
0f,
1f,
0f,
1f,
ScaleAnimation.RELATIVE_TO_SELF,
0.5f,
ScaleAnimation.RELATIVE_TO_SELF,
0.5f
)
scaleAnimation.interpolator = FastOutSlowInInterpolator()
scaleAnimation.duration = 800
scaleAnimation.setAnimationListener(object : Animation.AnimationListener {
override fun onAnimationStart(animation: Animation) {}
override fun onAnimationEnd(animation: Animation) {
qrVerified.postDelayed({
val scaleAnimation = ScaleAnimation(
1f,
0f,
1f,
0f,
ScaleAnimation.RELATIVE_TO_SELF,
0.5f,
ScaleAnimation.RELATIVE_TO_SELF,
0.5f
)
scaleAnimation.interpolator = AnticipateInterpolator()
scaleAnimation.duration = 500
ViewUtil.animateOut(qrVerified, scaleAnimation, GONE)
ViewUtil.fadeIn(qrCode, 800)
qrCodeContainer.isEnabled = true
tapLabel.setText(context.getString(R.string.verify_display_fragment__tap_to_scan))
}, 2000)
}
override fun onAnimationRepeat(animation: Animation) {}
})
ViewUtil.fadeOut(qrCode, 200, INVISIBLE)
ViewUtil.animateIn(qrVerified, scaleAnimation)
qrCodeContainer.isEnabled = false
}
private fun createVerifiedBitmap(width: Int, height: Int, @DrawableRes id: Int): Bitmap {
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val check = BitmapFactory.decodeResource(resources, id)
val offset = ((width - check.width) / 2).toFloat()
canvas.drawBitmap(check, offset, offset, null)
return bitmap
}
private fun setCodeSegment(codeView: TextView, segment: String) {
val valueAnimator = ValueAnimator.ofInt(0, segment.toInt())
valueAnimator.addUpdateListener { animation: ValueAnimator ->
val value = animation.animatedValue as Int
codeView.text = String.format(Locale.getDefault(), "%05d", value)
}
valueAnimator.duration = 1000
valueAnimator.start()
}
}

View File

@@ -4391,7 +4391,7 @@ public class ConversationParentFragment extends Fragment
public void onClicked(final List<IdentityRecord> unverifiedIdentities) {
Log.i(TAG, "onClicked: " + unverifiedIdentities.size());
if (unverifiedIdentities.size() == 1) {
startActivity(VerifyIdentityActivity.newIntent(requireContext(), unverifiedIdentities.get(0), false));
VerifyIdentityActivity.startOrShowExchangeMessagesDialog(requireContext(), unverifiedIdentities.get(0), false);
} else {
String[] unverifiedNames = new String[unverifiedIdentities.size()];
@@ -4403,7 +4403,7 @@ public class ConversationParentFragment extends Fragment
builder.setIcon(R.drawable.ic_warning);
builder.setTitle(R.string.ConversationFragment__no_longer_verified);
builder.setItems(unverifiedNames, (dialog, which) -> {
startActivity(VerifyIdentityActivity.newIntent(requireContext(), unverifiedIdentities.get(which), false));
VerifyIdentityActivity.startOrShowExchangeMessagesDialog(requireContext(), unverifiedIdentities.get(which), false);
});
builder.show();
}

View File

@@ -225,7 +225,7 @@ public final class SafetyNumberChangeDialog extends DialogFragment implements Sa
@Override
public void onViewIdentityRecord(@NonNull IdentityRecord identityRecord) {
startActivity(VerifyIdentityActivity.newIntent(requireContext(), identityRecord));
VerifyIdentityActivity.startOrShowExchangeMessagesDialog(requireContext(), identityRecord);
}
public interface Callback {

View File

@@ -55,7 +55,7 @@ object ConversationDialogs {
{ ApplicationDependencies.getProtocolStore().aci().identities().getIdentityRecord(recipient.id) },
{ identityRecord ->
identityRecord.ifPresent {
fragment.startActivity(VerifyIdentityActivity.newIntent(fragment.requireContext(), identityRecord.get()))
VerifyIdentityActivity.startOrShowExchangeMessagesDialog(fragment.requireContext(), identityRecord.get())
}
d.dismiss()
}

View File

@@ -3246,7 +3246,7 @@ class ConversationFragment :
override fun onUnverifiedBannerClicked(unverifiedIdentities: List<IdentityRecord>) {
if (unverifiedIdentities.size == 1) {
startActivity(VerifyIdentityActivity.newIntent(requireContext(), unverifiedIdentities[0], false))
VerifyIdentityActivity.startOrShowExchangeMessagesDialog(requireContext(), unverifiedIdentities[0], false)
} else {
val unverifiedNames = unverifiedIdentities
.map { Recipient.resolved(it.recipientId).getDisplayName(requireContext()) }
@@ -3255,7 +3255,7 @@ class ConversationFragment :
MaterialAlertDialogBuilder(requireContext())
.setIcon(R.drawable.ic_warning)
.setTitle(R.string.ConversationFragment__no_longer_verified)
.setItems(unverifiedNames) { _, which: Int -> startActivity(VerifyIdentityActivity.newIntent(requireContext(), unverifiedIdentities[which], false)) }
.setItems(unverifiedNames) { _, which: Int -> VerifyIdentityActivity.startOrShowExchangeMessagesDialog(requireContext(), unverifiedIdentities[which], false) }
.show()
}
}

View File

@@ -17,6 +17,7 @@ public class UiHints extends SignalStoreValues {
private static final String HAS_SEEN_USERNAME_EDUCATION = "uihints.has_seen_username_education";
private static final String HAS_SEEN_TEXT_FORMATTING_ALERT = "uihints.text_formatting.has_seen_alert";
private static final String HAS_NOT_SEEN_EDIT_MESSAGE_BETA_ALERT = "uihints.edit_message.has_not_seen_beta_alert";
private static final String HAS_SEEN_SAFETY_NUMBER_NUX = "uihints.has_seen_safety_number_nux";
UiHints(@NonNull KeyValueStore store) {
super(store);
@@ -109,4 +110,12 @@ public class UiHints extends SignalStoreValues {
public void markHasSeenEditMessageBetaAlert() {
putBoolean(HAS_NOT_SEEN_EDIT_MESSAGE_BETA_ALERT, false);
}
public boolean hasSeenSafetyNumberUpdateNux() {
return getBoolean(HAS_SEEN_SAFETY_NUMBER_NUX, false);
}
public void markHasSeenSafetyNumberUpdateNux() {
putBoolean(HAS_SEEN_SAFETY_NUMBER_NUX, true);
}
}

View File

@@ -7,7 +7,9 @@ import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import androidx.core.app.ActivityCompat;
import androidx.fragment.app.FragmentActivity;
import androidx.fragment.app.FragmentManager;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Transformations;
@@ -181,7 +183,7 @@ final class RecipientDialogViewModel extends ViewModel {
}
void onViewSafetyNumberClicked(@NonNull Activity activity, @NonNull IdentityRecord identityRecord) {
activity.startActivity(VerifyIdentityActivity.newIntent(activity, identityRecord));
VerifyIdentityActivity.startOrShowExchangeMessagesDialog(activity, identityRecord);
}
void onAvatarClicked(@NonNull Activity activity) {

View File

@@ -109,6 +109,7 @@ public final class FeatureFlags {
private static final String CDS_COMPAT_MODE = "global.cds.return_acis_without_uaks";
private static final String CONVERSATION_FRAGMENT_V2 = "android.conversationFragmentV2";
private static final String SAFETY_NUMBER_ACI = "global.safetyNumberAci";
/**
* We will only store remote values for flags in this set. If you want a flag to be controllable
* remotely, place it in here.
@@ -167,7 +168,8 @@ public final class FeatureFlags {
AD_HOC_CALLING,
SVR2_KILLSWITCH,
CDS_COMPAT_MODE,
CONVERSATION_FRAGMENT_V2
CONVERSATION_FRAGMENT_V2,
SAFETY_NUMBER_ACI
);
@VisibleForTesting
@@ -233,7 +235,8 @@ public final class FeatureFlags {
MAX_ATTACHMENT_SIZE_BYTES,
SVR2_KILLSWITCH,
CDS_COMPAT_MODE,
CONVERSATION_FRAGMENT_V2
CONVERSATION_FRAGMENT_V2,
SAFETY_NUMBER_ACI
);
/**
@@ -340,6 +343,14 @@ public final class FeatureFlags {
return getBoolean(VERIFY_V2, false);
}
/** Whether or not we show the ACI safety number as the default initial safety number. */
public static boolean showAciSafetyNumberAsDefault() {
long estimatedServerTimeSeconds = (System.currentTimeMillis() - SignalStore.misc().getLastKnownServerTimeOffset()) / 1000;
long flagEnableTimeSeconds = getLong(SAFETY_NUMBER_ACI, Long.MAX_VALUE);
return estimatedServerTimeSeconds > flagEnableTimeSeconds;
}
/** The raw client expiration JSON string. */
public static String clientExpiration() {
return getString(CLIENT_EXPIRATION, null);

View File

@@ -1,36 +0,0 @@
package org.thoughtcrime.securesms.util;
import android.content.Context;
import android.text.style.ClickableSpan;
import android.view.View;
import androidx.annotation.NonNull;
import org.signal.libsignal.protocol.IdentityKey;
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.verify.VerifyIdentityActivity;
public class VerifySpan extends ClickableSpan {
private final Context context;
private final RecipientId recipientId;
private final IdentityKey identityKey;
public VerifySpan(@NonNull Context context, @NonNull IdentityKeyMismatch mismatch) {
this.context = context;
this.recipientId = mismatch.getRecipientId(context);
this.identityKey = mismatch.getIdentityKey();
}
public VerifySpan(@NonNull Context context, @NonNull RecipientId recipientId, @NonNull IdentityKey identityKey) {
this.context = context;
this.recipientId = recipientId;
this.identityKey = identityKey;
}
@Override
public void onClick(@NonNull View widget) {
context.startActivity(VerifyIdentityActivity.newIntent(context, recipientId, identityKey, false));
}
}

View File

@@ -0,0 +1,71 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.verify
import android.animation.Animator
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.FragmentManager
import com.airbnb.lottie.LottieDrawable
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.animation.AnimationCompleteListener
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.databinding.SafetyNumberPnpEducationBottomSheetBinding
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.visible
class PnpSafetyNumberEducationDialogFragment : FixedRoundedCornerBottomSheetDialogFragment() {
override val peekHeightPercentage: Float = 0.66f
private val binding by ViewBinderDelegate(SafetyNumberPnpEducationBottomSheetBinding::bind)
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
return inflater.inflate(R.layout.safety_number_pnp_education_bottom_sheet, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.lottie.visible = true
binding.lottie.playAnimation()
binding.lottie.addAnimatorListener(object : AnimationCompleteListener() {
override fun onAnimationEnd(animation: Animator) {
binding.lottie.removeAnimatorListener(this)
binding.lottie.setMinAndMaxFrame(60, 360)
binding.lottie.repeatMode = LottieDrawable.RESTART
binding.lottie.repeatCount = LottieDrawable.INFINITE
binding.lottie.frame = 60
binding.lottie.playAnimation()
}
})
binding.okay.setOnClickListener {
SignalStore.uiHints().markHasSeenSafetyNumberUpdateNux()
dismiss()
}
binding.help.setOnClickListener {
CommunicationActions.openBrowserLink(requireContext(), "https://support.signal.org/hc/en-us/articles/360007060632")
}
}
companion object {
@JvmStatic
fun showIfNeeded(fragmentManager: FragmentManager) {
if (SignalStore.uiHints().hasSeenSafetyNumberUpdateNux()) {
return
}
val fragment = PnpSafetyNumberEducationDialogFragment()
fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
}

View File

@@ -1,593 +0,0 @@
package org.thoughtcrime.securesms.verify;
import android.animation.TypeEvaluator;
import android.animation.ValueAnimator;
import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.PorterDuff;
import android.graphics.drawable.BitmapDrawable;
import android.os.Bundle;
import android.text.Html;
import android.text.TextUtils;
import android.text.method.LinkMovementMethod;
import android.view.ContextMenu;
import android.view.LayoutInflater;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewTreeObserver;
import android.view.animation.Animation;
import android.view.animation.AnticipateInterpolator;
import android.view.animation.ScaleAnimation;
import android.widget.Button;
import android.widget.ImageView;
import android.widget.ScrollView;
import android.widget.TextSwitcher;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.Toolbar;
import androidx.fragment.app.Fragment;
import androidx.interpolator.view.animation.FastOutSlowInInterpolator;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
import org.signal.libsignal.protocol.IdentityKey;
import org.signal.libsignal.protocol.fingerprint.Fingerprint;
import org.signal.libsignal.protocol.fingerprint.FingerprintVersionMismatchException;
import org.signal.libsignal.protocol.fingerprint.NumericFingerprintGenerator;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.crypto.IdentityKeyParcelable;
import org.thoughtcrime.securesms.crypto.ReentrantSessionLock;
import org.thoughtcrime.securesms.database.IdentityTable;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobs.MultiDeviceVerifiedUpdateJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.qr.QrCodeUtil;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.IdentityUtil;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.signal.core.util.concurrent.SimpleTask;
import org.whispersystems.signalservice.api.SignalSessionLock;
import java.nio.charset.Charset;
import java.util.Locale;
/**
* Fragment to display a user's identity key.
*/
public class VerifyDisplayFragment extends Fragment implements ViewTreeObserver.OnScrollChangedListener {
private static final String TAG = Log.tag(VerifyDisplayFragment.class);
private static final String RECIPIENT_ID = "recipient_id";
private static final String REMOTE_IDENTITY = "remote_identity";
private static final String LOCAL_IDENTITY = "local_identity";
private static final String LOCAL_NUMBER = "local_number";
private static final String VERIFIED_STATE = "verified_state";
private LiveRecipient recipient;
private IdentityKey localIdentity;
private IdentityKey remoteIdentity;
private Fingerprint fingerprint;
private Toolbar toolbar;
private ScrollView scrollView;
private View numbersContainer;
private View loading;
private View qrCodeContainer;
private ImageView qrCode;
private ImageView qrVerified;
private TextSwitcher tapLabel;
private TextView description;
private Callback callback;
private Button verifyButton;
private View toolbarShadow;
private View bottomShadow;
private TextView[] codes = new TextView[12];
private boolean animateSuccessOnDraw = false;
private boolean animateFailureOnDraw = false;
private boolean currentVerifiedState = false;
static VerifyDisplayFragment create(@NonNull RecipientId recipientId,
@NonNull IdentityKeyParcelable remoteIdentity,
@NonNull IdentityKeyParcelable localIdentity,
@NonNull String localNumber,
boolean verifiedState)
{
Bundle extras = new Bundle();
extras.putParcelable(RECIPIENT_ID, recipientId);
extras.putParcelable(REMOTE_IDENTITY, remoteIdentity);
extras.putParcelable(LOCAL_IDENTITY, localIdentity);
extras.putString(LOCAL_NUMBER, localNumber);
extras.putBoolean(VERIFIED_STATE, verifiedState);
VerifyDisplayFragment fragment = new VerifyDisplayFragment();
fragment.setArguments(extras);
return fragment;
}
@Override
public void onAttach(@NonNull Context context) {
super.onAttach(context);
if (context instanceof Callback) {
callback = (Callback) context;
} else if (getParentFragment() instanceof Callback) {
callback = (Callback) getParentFragment();
} else {
throw new ClassCastException("Cannot find ScanListener in parent component");
}
}
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup viewGroup, Bundle bundle) {
return ViewUtil.inflate(inflater, viewGroup, R.layout.verify_display_fragment);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
this.toolbar = view.findViewById(R.id.toolbar);
this.scrollView = view.findViewById(R.id.scroll_view);
this.numbersContainer = view.findViewById(R.id.number_table);
this.loading = view.findViewById(R.id.loading);
this.qrCodeContainer = view.findViewById(R.id.qr_code_container);
this.qrCode = view.findViewById(R.id.qr_code);
this.verifyButton = view.findViewById(R.id.verify_button);
this.qrVerified = view.findViewById(R.id.qr_verified);
this.description = view.findViewById(R.id.description);
this.tapLabel = view.findViewById(R.id.tap_label);
this.toolbarShadow = view.findViewById(R.id.toolbar_shadow);
this.bottomShadow = view.findViewById(R.id.verify_identity_bottom_shadow);
this.codes[0] = view.findViewById(R.id.code_first);
this.codes[1] = view.findViewById(R.id.code_second);
this.codes[2] = view.findViewById(R.id.code_third);
this.codes[3] = view.findViewById(R.id.code_fourth);
this.codes[4] = view.findViewById(R.id.code_fifth);
this.codes[5] = view.findViewById(R.id.code_sixth);
this.codes[6] = view.findViewById(R.id.code_seventh);
this.codes[7] = view.findViewById(R.id.code_eighth);
this.codes[8] = view.findViewById(R.id.code_ninth);
this.codes[9] = view.findViewById(R.id.code_tenth);
this.codes[10] = view.findViewById(R.id.code_eleventh);
this.codes[11] = view.findViewById(R.id.code_twelth);
this.qrCodeContainer.setOnClickListener(v -> callback.onQrCodeContainerClicked());
this.registerForContextMenu(numbersContainer);
updateVerifyButton(getArguments().getBoolean(VERIFIED_STATE, false), false);
this.verifyButton.setOnClickListener((button -> updateVerifyButton(!currentVerifiedState, true)));
this.scrollView.getViewTreeObserver().addOnScrollChangedListener(this);
toolbar.setNavigationOnClickListener(v -> requireActivity().onBackPressed());
toolbar.setOnMenuItemClickListener(this::onToolbarOptionsItemSelected);
toolbar.setTitle(R.string.AndroidManifest__verify_safety_number);
}
@Override
public void onViewStateRestored(@Nullable Bundle savedInstanceState) {
super.onViewStateRestored(savedInstanceState);
initializeFingerprint();
}
@Override
public void onDestroyView() {
this.scrollView.getViewTreeObserver().removeOnScrollChangedListener(this);
super.onDestroyView();
}
private void initializeFingerprint() {
RecipientId recipientId = getArguments().getParcelable(RECIPIENT_ID);
IdentityKeyParcelable localIdentityParcelable = getArguments().getParcelable(LOCAL_IDENTITY);
IdentityKeyParcelable remoteIdentityParcelable = getArguments().getParcelable(REMOTE_IDENTITY);
this.localIdentity = localIdentityParcelable.get();
this.recipient = Recipient.live(recipientId);
this.remoteIdentity = remoteIdentityParcelable.get();
int version;
byte[] localId;
byte[] remoteId;
//noinspection WrongThread
Recipient resolved = recipient.resolve();
if (FeatureFlags.verifyV2() && resolved.getServiceId().isPresent()) {
Log.i(TAG, "Using UUID (version 2).");
version = 2;
localId = SignalStore.account().requireAci().toByteArray();
remoteId = resolved.requireServiceId().toByteArray();
} else if (!FeatureFlags.verifyV2() && resolved.getE164().isPresent()) {
Log.i(TAG, "Using E164 (version 1).");
version = 1;
localId = Recipient.self().requireE164().getBytes();
remoteId = resolved.requireE164().getBytes();
} else {
Log.w(TAG, String.format(Locale.ENGLISH, "Could not show proper verification! verifyV2: %s, hasUuid: %s, hasE164: %s", FeatureFlags.verifyV2(), resolved.getServiceId().isPresent(), resolved.getE164().isPresent()));
new MaterialAlertDialogBuilder(requireContext())
.setMessage(getString(R.string.VerifyIdentityActivity_you_must_first_exchange_messages_in_order_to_view, resolved.getDisplayName(requireContext())))
.setPositiveButton(android.R.string.ok, (dialog, which) -> requireActivity().finish())
.setOnDismissListener(dialog -> {
requireActivity().finish();
dialog.dismiss();
})
.show();
return;
}
this.recipient.observe(this, this::setRecipientText);
SimpleTask.run(() -> new NumericFingerprintGenerator(5200).createFor(version,
localId, localIdentity,
remoteId, remoteIdentity),
fingerprint -> {
if (getActivity() == null) return;
VerifyDisplayFragment.this.fingerprint = fingerprint;
setFingerprintViews(fingerprint, true);
initializeOptionsMenu();
});
}
@Override
public void onResume() {
super.onResume();
setRecipientText(recipient.get());
if (fingerprint != null) {
setFingerprintViews(fingerprint, false);
}
if (animateSuccessOnDraw) {
animateSuccessOnDraw = false;
animateVerifiedSuccess();
} else if (animateFailureOnDraw) {
animateFailureOnDraw = false;
animateVerifiedFailure();
}
ThreadUtil.postToMain(this::onScrollChanged);
}
@Override
public void onCreateContextMenu(ContextMenu menu, View view,
ContextMenu.ContextMenuInfo menuInfo)
{
super.onCreateContextMenu(menu, view, menuInfo);
if (fingerprint != null) {
MenuInflater inflater = getActivity().getMenuInflater();
inflater.inflate(R.menu.verify_display_fragment_context_menu, menu);
}
}
@Override
public boolean onContextItemSelected(MenuItem item) {
if (fingerprint == null) return super.onContextItemSelected(item);
if (item.getItemId() == R.id.menu_copy) {
handleCopyToClipboard(fingerprint, codes.length);
return true;
} else if (item.getItemId() == R.id.menu_compare) {
handleCompareWithClipboard(fingerprint);
return true;
} else {
return super.onContextItemSelected(item);
}
}
private void initializeOptionsMenu() {
if (fingerprint != null) {
requireActivity().getMenuInflater().inflate(R.menu.verify_identity, toolbar.getMenu());
}
}
public boolean onToolbarOptionsItemSelected(MenuItem item) {
if (item.getItemId() == R.id.verify_identity__share) {
handleShare(fingerprint, codes.length);
return true;
}
return false;
}
public void setScannedFingerprint(String scanned) {
try {
if (fingerprint.getScannableFingerprint().compareTo(scanned.getBytes("ISO-8859-1"))) {
this.animateSuccessOnDraw = true;
} else {
this.animateFailureOnDraw = true;
}
} catch (FingerprintVersionMismatchException e) {
Log.w(TAG, e);
if (e.getOurVersion() < e.getTheirVersion()) {
Toast.makeText(getActivity(), R.string.VerifyIdentityActivity_your_contact_is_running_a_newer_version_of_Signal, Toast.LENGTH_LONG).show();
} else {
Toast.makeText(getActivity(), R.string.VerifyIdentityActivity_your_contact_is_running_an_old_version_of_signal, Toast.LENGTH_LONG).show();
}
this.animateFailureOnDraw = true;
} catch (Exception e) {
Log.w(TAG, e);
Toast.makeText(getActivity(), R.string.VerifyIdentityActivity_the_scanned_qr_code_is_not_a_correctly_formatted_safety_number, Toast.LENGTH_LONG).show();
this.animateFailureOnDraw = true;
}
}
private @NonNull String getFormattedSafetyNumbers(@NonNull Fingerprint fingerprint, int segmentCount) {
String[] segments = getSegments(fingerprint, segmentCount);
StringBuilder result = new StringBuilder();
for (int i = 0; i < segments.length; i++) {
result.append(segments[i]);
if (i != segments.length - 1) {
if (((i + 1) % 4) == 0) result.append('\n');
else result.append(' ');
}
}
return result.toString();
}
private void handleCopyToClipboard(Fingerprint fingerprint, int segmentCount) {
Util.writeTextToClipboard(requireContext(), "Safety numbers", getFormattedSafetyNumbers(fingerprint, segmentCount));
}
private void handleCompareWithClipboard(Fingerprint fingerprint) {
String clipboardData = Util.readTextFromClipboard(getActivity());
if (clipboardData == null) {
Toast.makeText(getActivity(), R.string.VerifyIdentityActivity_no_safety_number_to_compare_was_found_in_the_clipboard, Toast.LENGTH_LONG).show();
return;
}
String numericClipboardData = clipboardData.replaceAll("\\D", "");
if (TextUtils.isEmpty(numericClipboardData) || numericClipboardData.length() != 60) {
Toast.makeText(getActivity(), R.string.VerifyIdentityActivity_no_safety_number_to_compare_was_found_in_the_clipboard, Toast.LENGTH_LONG).show();
return;
}
if (fingerprint.getDisplayableFingerprint().getDisplayText().equals(numericClipboardData)) {
animateVerifiedSuccess();
} else {
animateVerifiedFailure();
}
}
private void handleShare(@NonNull Fingerprint fingerprint, int segmentCount) {
String shareString =
getString(R.string.VerifyIdentityActivity_our_signal_safety_number) + "\n" +
getFormattedSafetyNumbers(fingerprint, segmentCount) + "\n";
Intent intent = new Intent();
intent.setAction(Intent.ACTION_SEND);
intent.putExtra(Intent.EXTRA_TEXT, shareString);
intent.setType("text/plain");
try {
startActivity(Intent.createChooser(intent, getString(R.string.VerifyIdentityActivity_share_safety_number_via)));
} catch (ActivityNotFoundException e) {
Toast.makeText(getActivity(), R.string.VerifyIdentityActivity_no_app_to_share_to, Toast.LENGTH_LONG).show();
}
}
private void setRecipientText(Recipient recipient) {
String escapedDisplayName = Html.escapeHtml(recipient.getDisplayName(getContext()));
description.setText(Html.fromHtml(String.format(getActivity().getString(R.string.verify_display_fragment__to_verify_the_security_of_your_end_to_end_encryption_with_s), escapedDisplayName)));
description.setMovementMethod(LinkMovementMethod.getInstance());
}
private void setFingerprintViews(Fingerprint fingerprint, boolean animate) {
String[] segments = getSegments(fingerprint, codes.length);
for (int i = 0; i < codes.length; i++) {
if (animate) setCodeSegment(codes[i], segments[i]);
else codes[i].setText(segments[i]);
}
byte[] qrCodeData = fingerprint.getScannableFingerprint().getSerialized();
String qrCodeString = new String(qrCodeData, Charset.forName("ISO-8859-1"));
Bitmap qrCodeBitmap = QrCodeUtil.create(qrCodeString);
qrCode.setImageBitmap(qrCodeBitmap);
if (animate) {
ViewUtil.fadeIn(qrCode, 1000);
ViewUtil.fadeIn(tapLabel, 1000);
ViewUtil.fadeOut(loading, 300, View.GONE);
} else {
qrCode.setVisibility(View.VISIBLE);
tapLabel.setVisibility(View.VISIBLE);
loading.setVisibility(View.GONE);
}
}
private void setCodeSegment(final TextView codeView, String segment) {
ValueAnimator valueAnimator = new ValueAnimator();
valueAnimator.setObjectValues(0, Integer.parseInt(segment));
valueAnimator.addUpdateListener(animation -> {
int value = (int) animation.getAnimatedValue();
codeView.setText(String.format(Locale.getDefault(), "%05d", value));
});
valueAnimator.setEvaluator(new TypeEvaluator<Integer>() {
public Integer evaluate(float fraction, Integer startValue, Integer endValue) {
return Math.round(startValue + (endValue - startValue) * fraction);
}
});
valueAnimator.setDuration(1000);
valueAnimator.start();
}
private String[] getSegments(Fingerprint fingerprint, int segmentCount) {
String[] segments = new String[segmentCount];
String digits = fingerprint.getDisplayableFingerprint().getDisplayText();
int partSize = digits.length() / segmentCount;
for (int i = 0; i < segmentCount; i++) {
segments[i] = digits.substring(i * partSize, (i * partSize) + partSize);
}
return segments;
}
private Bitmap createVerifiedBitmap(int width, int height, @DrawableRes int id) {
Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(bitmap);
Bitmap check = BitmapFactory.decodeResource(getResources(), id);
float offset = (width - check.getWidth()) / 2;
canvas.drawBitmap(check, offset, offset, null);
return bitmap;
}
private void animateVerifiedSuccess() {
Bitmap qrBitmap = ((BitmapDrawable) qrCode.getDrawable()).getBitmap();
Bitmap qrSuccess = createVerifiedBitmap(qrBitmap.getWidth(), qrBitmap.getHeight(), R.drawable.ic_check_white_48dp);
qrVerified.setImageBitmap(qrSuccess);
qrVerified.getBackground().setColorFilter(getResources().getColor(R.color.green_500), PorterDuff.Mode.MULTIPLY);
tapLabel.setText(getString(R.string.verify_display_fragment__successful_match));
animateVerified();
}
private void animateVerifiedFailure() {
Bitmap qrBitmap = ((BitmapDrawable) qrCode.getDrawable()).getBitmap();
Bitmap qrSuccess = createVerifiedBitmap(qrBitmap.getWidth(), qrBitmap.getHeight(), R.drawable.ic_close_white_48dp);
qrVerified.setImageBitmap(qrSuccess);
qrVerified.getBackground().setColorFilter(getResources().getColor(R.color.red_500), PorterDuff.Mode.MULTIPLY);
tapLabel.setText(getString(R.string.verify_display_fragment__failed_to_verify_safety_number));
animateVerified();
}
private void animateVerified() {
ScaleAnimation scaleAnimation = new ScaleAnimation(0, 1, 0, 1,
ScaleAnimation.RELATIVE_TO_SELF, 0.5f,
ScaleAnimation.RELATIVE_TO_SELF, 0.5f);
scaleAnimation.setInterpolator(new FastOutSlowInInterpolator());
scaleAnimation.setDuration(800);
scaleAnimation.setAnimationListener(new Animation.AnimationListener() {
@Override
public void onAnimationStart(Animation animation) {}
@Override
public void onAnimationEnd(Animation animation) {
qrVerified.postDelayed(() -> {
ScaleAnimation scaleAnimation1 = new ScaleAnimation(1, 0, 1, 0,
ScaleAnimation.RELATIVE_TO_SELF, 0.5f,
ScaleAnimation.RELATIVE_TO_SELF, 0.5f);
scaleAnimation1.setInterpolator(new AnticipateInterpolator());
scaleAnimation1.setDuration(500);
ViewUtil.animateOut(qrVerified, scaleAnimation1, View.GONE);
ViewUtil.fadeIn(qrCode, 800);
qrCodeContainer.setEnabled(true);
tapLabel.setText(getString(R.string.verify_display_fragment__tap_to_scan));
}, 2000);
}
@Override
public void onAnimationRepeat(Animation animation) {}
});
ViewUtil.fadeOut(qrCode, 200, View.INVISIBLE);
ViewUtil.animateIn(qrVerified, scaleAnimation);
qrCodeContainer.setEnabled(false);
}
private void updateVerifyButton(boolean verified, boolean update) {
currentVerifiedState = verified;
if (verified) {
verifyButton.setText(R.string.verify_display_fragment__clear_verification);
} else {
verifyButton.setText(R.string.verify_display_fragment__mark_as_verified);
}
if (update) {
final RecipientId recipientId = recipient.getId();
final Context context = requireContext().getApplicationContext();
SignalExecutors.BOUNDED.execute(() -> {
try (SignalSessionLock.Lock unused = ReentrantSessionLock.INSTANCE.acquire()) {
if (verified) {
Log.i(TAG, "Saving identity: " + recipientId);
ApplicationDependencies.getProtocolStore().aci().identities()
.saveIdentityWithoutSideEffects(recipientId,
Recipient.resolved(recipientId).requireServiceId(),
remoteIdentity,
IdentityTable.VerifiedStatus.VERIFIED,
false,
System.currentTimeMillis(),
true);
} else {
ApplicationDependencies.getProtocolStore().aci().identities().setVerified(recipientId, remoteIdentity, IdentityTable.VerifiedStatus.DEFAULT);
}
ApplicationDependencies.getJobManager()
.add(new MultiDeviceVerifiedUpdateJob(recipientId,
remoteIdentity,
verified ? IdentityTable.VerifiedStatus.VERIFIED
: IdentityTable.VerifiedStatus.DEFAULT));
StorageSyncHelper.scheduleSyncForDataChange();
IdentityUtil.markIdentityVerified(context, recipient.get(), verified, false);
}
});
}
}
@Override public void onScrollChanged() {
if (scrollView.canScrollVertically(-1)) {
if (toolbarShadow.getVisibility() != View.VISIBLE) {
ViewUtil.fadeIn(toolbarShadow, 250);
}
} else {
if (toolbarShadow.getVisibility() != View.GONE) {
ViewUtil.fadeOut(toolbarShadow, 250);
}
}
if (scrollView.canScrollVertically(1)) {
if (bottomShadow.getVisibility() != View.VISIBLE) {
ViewUtil.fadeIn(bottomShadow, 250);
}
} else {
ViewUtil.fadeOut(bottomShadow, 250);
}
}
interface Callback {
void onQrCodeContainerClicked();
}
}

View File

@@ -0,0 +1,432 @@
package org.thoughtcrime.securesms.verify
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.os.Bundle
import android.text.Html
import android.text.TextUtils
import android.text.method.LinkMovementMethod
import android.view.ContextMenu
import android.view.ContextMenu.ContextMenuInfo
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.view.ViewTreeObserver.OnScrollChangedListener
import android.widget.Toast
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import org.signal.core.util.ThreadUtil
import org.signal.core.util.logging.Log
import org.signal.core.util.requireParcelableCompat
import org.signal.libsignal.protocol.fingerprint.Fingerprint
import org.signal.libsignal.protocol.fingerprint.FingerprintVersionMismatchException
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.components.verify.SafetyNumberQrView
import org.thoughtcrime.securesms.components.verify.SafetyNumberQrView.Companion.getSegments
import org.thoughtcrime.securesms.crypto.IdentityKeyParcelable
import org.thoughtcrime.securesms.databinding.VerifyDisplayFragmentBinding
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.visible
import org.thoughtcrime.securesms.verify.PnpSafetyNumberEducationDialogFragment.Companion.showIfNeeded
import java.nio.charset.StandardCharsets
import java.util.Locale
/**
* Fragment to display a user's identity key.
*/
class VerifyDisplayFragment : Fragment(), OnScrollChangedListener {
private lateinit var viewModel: VerifySafetyNumberViewModel
private val binding by ViewBinderDelegate(VerifyDisplayFragmentBinding::bind)
private lateinit var safetyNumberAdapter: SafetyNumberAdapter
private var selectedFingerPrint = 0
private var callback: Callback? = null
private var animateSuccessOnDraw = false
private var animateFailureOnDraw = false
private var currentVerifiedState = false
override fun onAttach(context: Context) {
super.onAttach(context)
callback = if (context is Callback) {
context
} else if (parentFragment is Callback) {
parentFragment as Callback?
} else {
throw ClassCastException("Cannot find ScanListener in parent component")
}
}
override fun onCreateView(inflater: LayoutInflater, viewGroup: ViewGroup?, bundle: Bundle?): View? {
return ViewUtil.inflate(inflater, viewGroup!!, R.layout.verify_display_fragment)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
initializeViewModel()
binding.safetyNumberUpdatingBannerText.text = Html.fromHtml(String.format(getString(R.string.verify_display_fragment__safety_numbers_are_updating_banner)))
updateVerifyButton(requireArguments().getBoolean(VERIFIED_STATE, false), false)
binding.verifyButton.setOnClickListener { updateVerifyButton(!currentVerifiedState, true) }
binding.scrollView.viewTreeObserver?.addOnScrollChangedListener(this)
binding.toolbar.setNavigationOnClickListener { requireActivity().onBackPressed() }
binding.toolbar.setTitle(R.string.AndroidManifest__verify_safety_number)
safetyNumberAdapter = SafetyNumberAdapter()
binding.verifyViewPager.adapter = safetyNumberAdapter
binding.verifyViewPager.registerOnPageChangeCallback(object : OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
selectedFingerPrint = position
}
})
val peekSize = resources.getDimensionPixelSize(R.dimen.safety_number_qr_peek)
val pageWidth = resources.getDimensionPixelSize(R.dimen.safety_number_qr_width)
val pageTransformer = ViewPager2.PageTransformer { page: View, position: Float -> page.translationX = -position * (peekSize + (page.width - pageWidth) / 2f) }
binding.verifyViewPager.setPageTransformer(pageTransformer)
binding.verifyViewPager.offscreenPageLimit = 1
TabLayoutMediator(binding.dotIndicators, binding.verifyViewPager) { _: TabLayout.Tab?, _: Int -> }.attach()
viewModel.recipient.observe(this) { recipient: Recipient -> setRecipientText(recipient) }
viewModel.getFingerprints().observe(viewLifecycleOwner) { fingerprints: List<SafetyNumberFingerprint>? ->
if (fingerprints == null) {
return@observe
}
val multipleCards = fingerprints.size > 1
binding.safetyNumberChangeBanner.visible = multipleCards
binding.dotIndicators.visible = multipleCards
if (fingerprints.isEmpty()) {
val resolved = viewModel.recipient.resolve()
Log.w(TAG, String.format(Locale.ENGLISH, "Could not show proper verification! verifyV2: %s, hasUuid: %s, hasE164: %s", FeatureFlags.verifyV2(), resolved.serviceId.isPresent, resolved.e164.isPresent))
MaterialAlertDialogBuilder(requireContext())
.setMessage(getString(R.string.VerifyIdentityActivity_you_must_first_exchange_messages_in_order_to_view, resolved.getDisplayName(requireContext())))
.setPositiveButton(android.R.string.ok) { dialog: DialogInterface?, which: Int -> requireActivity().finish() }
.setOnDismissListener { dialog: DialogInterface ->
requireActivity().finish()
dialog.dismiss()
}
.show()
return@observe
}
safetyNumberAdapter.setFingerprints(fingerprints)
}
binding.verifyViewPager.currentItem = selectedFingerPrint
}
private fun initializeViewModel() {
val recipientId = requireArguments().requireParcelableCompat(RECIPIENT_ID, RecipientId::class.java)
val localIdentity = requireArguments().requireParcelableCompat(LOCAL_IDENTITY, IdentityKeyParcelable::class.java).get()!!
val remoteIdentity = requireArguments().requireParcelableCompat(REMOTE_IDENTITY, IdentityKeyParcelable::class.java).get()!!
viewModel = ViewModelProvider(this, VerifySafetyNumberViewModel.Factory(recipientId, localIdentity, remoteIdentity)).get(VerifySafetyNumberViewModel::class.java)
}
override fun onStart() {
super.onStart()
showIfNeeded(childFragmentManager)
}
override fun onResume() {
super.onResume()
setRecipientText(viewModel.recipient.get())
val selectedSnapshot = selectedFingerPrint
if (animateSuccessOnDraw) {
animateSuccessOnDraw = false
ThreadUtil.postToMain {
animateSuccess(selectedSnapshot)
}
ThreadUtil.postToMain {
binding.verifyViewPager.currentItem = selectedSnapshot
}
} else if (animateFailureOnDraw) {
animateFailureOnDraw = false
ThreadUtil.postToMain {
animateFailure(selectedSnapshot)
}
ThreadUtil.postToMain {
binding.verifyViewPager.currentItem = selectedSnapshot
}
}
ThreadUtil.postToMain { onScrollChanged() }
}
override fun onCreateContextMenu(
menu: ContextMenu,
view: View,
menuInfo: ContextMenuInfo?
) {
super.onCreateContextMenu(menu, view, menuInfo)
val fingerprints = viewModel.getFingerprints().value
if (!fingerprints.isNullOrEmpty()) {
val inflater = requireActivity().menuInflater
inflater.inflate(R.menu.verify_display_fragment_context_menu, menu)
}
}
override fun onContextItemSelected(item: MenuItem): Boolean {
if (currentFingerprint == null) return super.onContextItemSelected(item)
return if (item.itemId == R.id.menu_copy) {
handleCopyToClipboard(currentFingerprint)
true
} else if (item.itemId == R.id.menu_compare) {
handleCompareWithClipboard()
true
} else {
super.onContextItemSelected(item)
}
}
private val currentFingerprint: Fingerprint?
get() {
val fingerprints = viewModel.getFingerprints().value ?: return null
return fingerprints[binding.verifyViewPager.currentItem].fingerprint
}
fun setScannedFingerprint(scanned: String) {
val fingerprints = viewModel.getFingerprints().value
var haveMatchingVersion = false
if (fingerprints != null) {
for (i in fingerprints.indices) {
try {
if (fingerprints[i].fingerprint.scannableFingerprint.compareTo(scanned.toByteArray(StandardCharsets.ISO_8859_1))) {
animateSuccessOnDraw = true
} else {
animateFailureOnDraw = true
}
haveMatchingVersion = true
selectedFingerPrint = i
break
} catch (e: FingerprintVersionMismatchException) {
Log.w(TAG, e)
} catch (e: Exception) {
Log.w(TAG, e)
Toast.makeText(activity, R.string.VerifyIdentityActivity_the_scanned_qr_code_is_not_a_correctly_formatted_safety_number, Toast.LENGTH_LONG).show()
animateFailureOnDraw = true
return
}
}
}
if (!haveMatchingVersion) {
Toast.makeText(activity, R.string.VerifyIdentityActivity_your_contact_is_running_a_newer_version_of_Signal, Toast.LENGTH_LONG).show()
animateFailureOnDraw = true
}
}
private fun getFormattedSafetyNumbers(fingerprint: Fingerprint): String {
val segments = getSegments(fingerprint)
val result = StringBuilder()
for (i in segments.indices) {
result.append(segments[i])
if (i != segments.size - 1) {
if ((i + 1) % 4 == 0) result.append('\n') else result.append(' ')
}
}
return result.toString()
}
private fun handleCopyToClipboard(fingerprint: Fingerprint?) {
Util.writeTextToClipboard(requireContext(), "Safety numbers", getFormattedSafetyNumbers(fingerprint!!))
}
private fun handleCompareWithClipboard() {
val clipboardData = Util.readTextFromClipboard(requireActivity())
if (clipboardData == null) {
Toast.makeText(requireActivity(), R.string.VerifyIdentityActivity_no_safety_number_to_compare_was_found_in_the_clipboard, Toast.LENGTH_LONG).show()
return
}
val numericClipboardData = clipboardData.replace("\\D".toRegex(), "")
if (TextUtils.isEmpty(numericClipboardData) || numericClipboardData.length != 60) {
Toast.makeText(requireActivity(), R.string.VerifyIdentityActivity_no_safety_number_to_compare_was_found_in_the_clipboard, Toast.LENGTH_LONG).show()
return
}
var success = false
val fingerprints = viewModel.getFingerprints().value
if (fingerprints != null) {
for (i in fingerprints.indices) {
val (_, _, _, _, _, fingerprint1) = fingerprints[i]
if (fingerprint1.displayableFingerprint.displayText == numericClipboardData) {
binding.verifyViewPager.currentItem = i
animateSuccess(i)
success = true
break
}
}
}
if (!success) {
animateFailure(selectedFingerPrint)
}
}
private fun animateSuccess(position: Int) {
safetyNumberAdapter.notifyItemChanged(position, true)
}
private fun animateFailure(position: Int) {
safetyNumberAdapter.notifyItemChanged(position, false)
}
private fun handleShare(fingerprint: Fingerprint) {
val shareString = """
${getString(R.string.VerifyIdentityActivity_our_signal_safety_number)}
${getFormattedSafetyNumbers(fingerprint)}
""".trimIndent()
val intent = Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, shareString)
}
try {
startActivity(Intent.createChooser(intent, getString(R.string.VerifyIdentityActivity_share_safety_number_via)))
} catch (e: ActivityNotFoundException) {
Toast.makeText(activity, R.string.VerifyIdentityActivity_no_app_to_share_to, Toast.LENGTH_LONG).show()
}
}
private fun setRecipientText(recipient: Recipient) {
val escapedDisplayName = Html.escapeHtml(recipient.getDisplayName(requireContext()))
binding.description.text = Html.fromHtml(String.format(getString(R.string.verify_display_fragment__to_verify_the_security_of_your_end_to_end_encryption_with_s_pnp), escapedDisplayName))
binding.description.movementMethod = LinkMovementMethod.getInstance()
}
private fun updateVerifyButton(verified: Boolean, update: Boolean) {
currentVerifiedState = verified
if (verified) {
binding.verifyButton.setText(R.string.verify_display_fragment__clear_verification)
} else {
binding.verifyButton.setText(R.string.verify_display_fragment__mark_as_verified)
}
if (update) {
viewModel.updateSafetyNumberVerification(verified)
}
}
override fun onScrollChanged() {
val fingerprints = viewModel.getFingerprints().value
if (binding.scrollView.canScrollVertically(-1) && fingerprints != null && fingerprints.size <= 1) {
if (binding.toolbarShadow.visibility != View.VISIBLE) {
ViewUtil.fadeIn(binding.toolbarShadow, 250)
}
} else {
if (binding.toolbarShadow.visibility != View.GONE) {
ViewUtil.fadeOut(binding.toolbarShadow, 250)
}
}
if (binding.scrollView.canScrollVertically(1)) {
if (binding.verifyIdentityBottomShadow.visibility != View.VISIBLE) {
ViewUtil.fadeIn(binding.verifyIdentityBottomShadow, 250)
}
} else {
ViewUtil.fadeOut(binding.verifyIdentityBottomShadow, 250)
}
}
internal interface Callback {
fun onQrCodeContainerClicked()
}
private class SafetyNumberQrViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val safetyNumberQrView: SafetyNumberQrView
init {
safetyNumberQrView = itemView.findViewById(R.id.safety_qr_view)
}
}
private inner class SafetyNumberAdapter : RecyclerView.Adapter<SafetyNumberQrViewHolder>() {
private var fingerprints: List<SafetyNumberFingerprint>? = null
fun setFingerprints(fingerprints: List<SafetyNumberFingerprint>?) {
if (fingerprints == this.fingerprints) {
return
}
this.fingerprints = fingerprints?.let { ArrayList(it) }
notifyDataSetChanged()
}
init {
setHasStableIds(true)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SafetyNumberQrViewHolder {
return SafetyNumberQrViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.safety_number_qr_page_fragment, parent, false))
}
override fun onBindViewHolder(holder: SafetyNumberQrViewHolder, position: Int) {
val (version, _, _, _, _, fingerprint1) = fingerprints!![position]
holder.safetyNumberQrView.setFingerprintViews(fingerprint1, true)
holder.safetyNumberQrView.setSafetyNumberType(version == 2)
holder.safetyNumberQrView.shareButton.setOnClickListener { v: View? -> handleShare(fingerprints!![position].fingerprint) }
holder.safetyNumberQrView.qrCodeContainer.setOnClickListener { v: View? -> callback!!.onQrCodeContainerClicked() }
registerForContextMenu(holder.safetyNumberQrView.numbersContainer)
}
override fun onBindViewHolder(holder: SafetyNumberQrViewHolder, position: Int, payloads: List<Any>) {
super.onBindViewHolder(holder, position, payloads)
for (payload in payloads) {
if (payload is Boolean) {
if (payload) {
holder.safetyNumberQrView.animateVerifiedSuccess()
} else {
holder.safetyNumberQrView.animateVerifiedFailure()
}
break
}
}
}
override fun getItemId(position: Int): Long {
return fingerprints!![position].version.toLong()
}
override fun getItemCount(): Int {
return if (fingerprints != null) fingerprints!!.size else 0
}
}
companion object {
private val TAG = Log.tag(VerifyDisplayFragment::class.java)
private const val RECIPIENT_ID = "recipient_id"
private const val REMOTE_IDENTITY = "remote_identity"
private const val LOCAL_IDENTITY = "local_identity"
private const val LOCAL_NUMBER = "local_number"
private const val VERIFIED_STATE = "verified_state"
fun create(
recipientId: RecipientId,
remoteIdentity: IdentityKeyParcelable,
localIdentity: IdentityKeyParcelable,
localNumber: String,
verifiedState: Boolean
): VerifyDisplayFragment {
val fragment = VerifyDisplayFragment()
fragment.arguments = Bundle().apply {
putParcelable(RECIPIENT_ID, recipientId)
putParcelable(REMOTE_IDENTITY, remoteIdentity)
putParcelable(LOCAL_IDENTITY, localIdentity)
putString(LOCAL_NUMBER, localNumber)
putBoolean(VERIFIED_STATE, verifiedState)
}
return fragment
}
}
}

View File

@@ -6,14 +6,20 @@ import android.os.Bundle;
import androidx.annotation.NonNull;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.signal.libsignal.protocol.IdentityKey;
import org.thoughtcrime.securesms.PassphraseRequiredActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.crypto.IdentityKeyParcelable;
import org.thoughtcrime.securesms.database.IdentityTable;
import org.thoughtcrime.securesms.database.model.IdentityRecord;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.FeatureFlags;
/**
* Activity for verifying identity keys.
@@ -28,23 +34,52 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity {
private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme();
public static Intent newIntent(@NonNull Context context,
@NonNull IdentityRecord identityRecord)
{
return newIntent(context,
identityRecord.getRecipientId(),
identityRecord.getIdentityKey(),
identityRecord.getVerifiedStatus() == IdentityTable.VerifiedStatus.VERIFIED);
public static void startOrShowExchangeMessagesDialog(@NonNull Context context,
@NonNull IdentityRecord identityRecord) {
startOrShowExchangeMessagesDialog(context, identityRecord.getRecipientId(), identityRecord.getIdentityKey(), identityRecord.getVerifiedStatus() == IdentityTable.VerifiedStatus.VERIFIED);
}
public static void startOrShowExchangeMessagesDialog(@NonNull Context context,
@NonNull IdentityRecord identityRecord,
boolean verified) {
startOrShowExchangeMessagesDialog(context, identityRecord.getRecipientId(), identityRecord.getIdentityKey(), verified);
}
public static void startOrShowExchangeMessagesDialog(@NonNull Context context,
@NonNull RecipientId recipientId,
@NonNull IdentityKey identityKey,
boolean verified) {
Recipient recipient = Recipient.live(recipientId).resolve();
if (FeatureFlags.showAciSafetyNumberAsDefault()) {
if (!recipient.hasServiceId()) {
showExchangeMessagesDialog(context);
return;
}
} else {
if (!recipient.hasServiceId() || !recipient.hasE164()) {
showExchangeMessagesDialog(context);
return;
}
}
context.startActivity(newIntent(context, recipientId, identityKey, verified));
}
private static void showExchangeMessagesDialog(@NonNull Context context) {
new MaterialAlertDialogBuilder(context)
.setMessage(R.string.VerifyIdentityActivity_dialog_exchange_messages_to_create_safety_number_message)
.setPositiveButton(R.string.VerifyIdentityActivity_dialog_exchange_messages_to_create_safety_number_ok, null)
.setNeutralButton(R.string.VerifyIdentityActivity_dialog_exchange_messages_to_create_safety_number_learn_more, (dialog, which) -> {
CommunicationActions.openBrowserLink(context, "https://support.signal.org/hc/en-us/articles/360007060632");
})
.show();
}
public static Intent newIntent(@NonNull Context context,
@NonNull IdentityRecord identityRecord,
boolean verified)
@NonNull IdentityRecord identityRecord)
{
return newIntent(context,
identityRecord.getRecipientId(),
identityRecord.getIdentityKey(),
verified);
return newIntent(context, identityRecord.getRecipientId(), identityRecord.getIdentityKey(), identityRecord.getVerifiedStatus() == IdentityTable.VerifiedStatus.VERIFIED);
}
public static Intent newIntent(@NonNull Context context,

View File

@@ -0,0 +1,179 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.verify
import android.content.Context
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.signal.libsignal.protocol.IdentityKey
import org.signal.libsignal.protocol.fingerprint.Fingerprint
import org.signal.libsignal.protocol.fingerprint.NumericFingerprintGenerator
import org.thoughtcrime.securesms.crypto.ReentrantSessionLock
import org.thoughtcrime.securesms.database.IdentityTable
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobs.MultiDeviceVerifiedUpdateJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.LiveRecipient
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.IdentityUtil
class VerifySafetyNumberViewModel(
private val recipientId: RecipientId,
private val localIdentity: IdentityKey,
private val remoteIdentity: IdentityKey
) : ViewModel() {
companion object {
val TAG = Log.tag(VerifySafetyNumberViewModel::class.java)
}
val recipient: LiveRecipient = Recipient.live(recipientId)
private val fingerprintListLiveData = MutableLiveData<List<SafetyNumberFingerprint>>()
init {
initializeFingerprints()
}
private fun initializeFingerprints() {
SignalExecutors.UNBOUNDED.execute {
val resolved = recipient.resolve()
val fingerprintList: MutableList<SafetyNumberFingerprint> = ArrayList(2)
val generator = NumericFingerprintGenerator(5200)
var aciFingerprint: SafetyNumberFingerprint? = null
var e164Fingerprint: SafetyNumberFingerprint? = null
if (resolved.e164.isPresent) {
val localIdentifier = Recipient.self().requireE164().toByteArray()
val remoteIdentifier = resolved.requireE164().toByteArray()
val version = 1
e164Fingerprint = SafetyNumberFingerprint(version, localIdentifier, localIdentity, remoteIdentifier, remoteIdentity, generator.createFor(version, localIdentifier, localIdentity, remoteIdentifier, remoteIdentity))
}
if (resolved.serviceId.isPresent) {
val localIdentifier = SignalStore.account().requireAci().toByteArray()
val remoteIdentifier = resolved.requireServiceId().toByteArray()
val version = 2
aciFingerprint = SafetyNumberFingerprint(version, localIdentifier, localIdentity, remoteIdentifier, remoteIdentity, generator.createFor(version, localIdentifier, localIdentity, remoteIdentifier, remoteIdentity))
}
if (FeatureFlags.showAciSafetyNumberAsDefault()) {
if (aciFingerprint != null) {
fingerprintList.add(aciFingerprint)
if (e164Fingerprint != null) {
fingerprintList.add(e164Fingerprint)
}
}
} else {
if (aciFingerprint != null && e164Fingerprint != null) {
fingerprintList.add(e164Fingerprint)
fingerprintList.add(aciFingerprint)
}
}
fingerprintListLiveData.postValue(fingerprintList)
}
}
fun getFingerprints(): LiveData<List<SafetyNumberFingerprint>> {
return fingerprintListLiveData
}
fun updateSafetyNumberVerification(verified: Boolean) {
val recipientId: RecipientId = recipientId
val context: Context = ApplicationDependencies.getApplication()
SignalExecutors.BOUNDED.execute {
ReentrantSessionLock.INSTANCE.acquire().use { unused ->
if (verified) {
Log.i(TAG, "Saving identity: $recipientId")
ApplicationDependencies.getProtocolStore().aci().identities()
.saveIdentityWithoutSideEffects(
recipientId,
recipient.resolve().requireServiceId(),
remoteIdentity,
IdentityTable.VerifiedStatus.VERIFIED,
false,
System.currentTimeMillis(),
true
)
} else {
ApplicationDependencies.getProtocolStore().aci().identities().setVerified(recipientId, remoteIdentity, IdentityTable.VerifiedStatus.DEFAULT)
}
ApplicationDependencies.getJobManager()
.add(
MultiDeviceVerifiedUpdateJob(
recipientId,
remoteIdentity,
if (verified) IdentityTable.VerifiedStatus.VERIFIED else IdentityTable.VerifiedStatus.DEFAULT
)
)
StorageSyncHelper.scheduleSyncForDataChange()
IdentityUtil.markIdentityVerified(context, recipient.get(), verified, false)
}
}
}
class Factory(
private val recipientId: RecipientId,
private val localIdentity: IdentityKey,
private val remoteIdentity: IdentityKey
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(VerifySafetyNumberViewModel(recipientId, localIdentity, remoteIdentity))!!
}
}
}
data class SafetyNumberFingerprint(
val version: Int = 0,
val localStableIdentifier: ByteArray?,
val localIdentityKey: IdentityKey? = null,
val remoteStableIdentifier: ByteArray?,
val remoteIdentityKey: IdentityKey? = null,
val fingerprint: Fingerprint
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as SafetyNumberFingerprint
if (version != other.version) return false
if (localStableIdentifier != null) {
if (other.localStableIdentifier == null) return false
if (!localStableIdentifier.contentEquals(other.localStableIdentifier)) return false
} else if (other.localStableIdentifier != null) return false
if (localIdentityKey != other.localIdentityKey) return false
if (remoteStableIdentifier != null) {
if (other.remoteStableIdentifier == null) return false
if (!remoteStableIdentifier.contentEquals(other.remoteStableIdentifier)) return false
} else if (other.remoteStableIdentifier != null) return false
if (remoteIdentityKey != other.remoteIdentityKey) return false
if (fingerprint != other.fingerprint) return false
return true
}
override fun hashCode(): Int {
var result = version
result = 31 * result + (localStableIdentifier?.contentHashCode() ?: 0)
result = 31 * result + (localIdentityKey?.hashCode() ?: 0)
result = 31 * result + (remoteStableIdentifier?.contentHashCode() ?: 0)
result = 31 * result + (remoteIdentityKey?.hashCode() ?: 0)
result = 31 * result + (fingerprint?.hashCode() ?: 0)
return result
}
}