mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-03-02 07:23:38 +00:00
Add support for displaying both ACI and e164 safety numbers.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user