diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt index b63ffd4c9a..cd5deb7fb0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt @@ -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) } ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/verify/SafetyNumberQrView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/verify/SafetyNumberQrView.kt new file mode 100644 index 0000000000..2a54679cd1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/verify/SafetyNumberQrView.kt @@ -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 { + val segments = arrayOfNulls(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 + + 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 = 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() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java index 862b09644e..899f0b7862 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java @@ -4391,7 +4391,7 @@ public class ConversationParentFragment extends Fragment public void onClicked(final List 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(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeDialog.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeDialog.java index 73cc7802fa..6897dfd50e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeDialog.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeDialog.java @@ -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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationDialogs.kt index e9ad8855d8..1266c66594 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationDialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationDialogs.kt @@ -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() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index b373973976..b96ee7fb6e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -3246,7 +3246,7 @@ class ConversationFragment : override fun onUnverifiedBannerClicked(unverifiedIdentities: List) { 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() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/UiHints.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/UiHints.java index 9e879fc576..bfaee3ef08 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/UiHints.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/UiHints.java @@ -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); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogViewModel.java index fcfa3af804..a6ba5481da 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogViewModel.java @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java index 1b786b62f3..706f1a2b20 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/VerifySpan.java b/app/src/main/java/org/thoughtcrime/securesms/util/VerifySpan.java deleted file mode 100644 index 72bce8734c..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/VerifySpan.java +++ /dev/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)); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/verify/PnpSafetyNumberEducationDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/verify/PnpSafetyNumberEducationDialogFragment.kt new file mode 100644 index 0000000000..5bef976f1a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/verify/PnpSafetyNumberEducationDialogFragment.kt @@ -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) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/verify/VerifyDisplayFragment.java b/app/src/main/java/org/thoughtcrime/securesms/verify/VerifyDisplayFragment.java deleted file mode 100644 index 668755c186..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/verify/VerifyDisplayFragment.java +++ /dev/null @@ -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() { - 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(); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/verify/VerifyDisplayFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/verify/VerifyDisplayFragment.kt new file mode 100644 index 0000000000..54da84900a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/verify/VerifyDisplayFragment.kt @@ -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? -> + 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() { + private var fingerprints: List? = null + + fun setFingerprints(fingerprints: List?) { + 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) { + 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 + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/verify/VerifyIdentityActivity.java b/app/src/main/java/org/thoughtcrime/securesms/verify/VerifyIdentityActivity.java index 84ef27d7cd..5cc0a96627 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/verify/VerifyIdentityActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/verify/VerifyIdentityActivity.java @@ -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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/verify/VerifySafetyNumberViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/verify/VerifySafetyNumberViewModel.kt new file mode 100644 index 0000000000..bb85f8b44e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/verify/VerifySafetyNumberViewModel.kt @@ -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>() + + init { + initializeFingerprints() + } + + private fun initializeFingerprints() { + SignalExecutors.UNBOUNDED.execute { + val resolved = recipient.resolve() + + val fingerprintList: MutableList = 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> { + 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 create(modelClass: Class): 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 + } +} diff --git a/app/src/main/res/drawable/qr_code_background.xml b/app/src/main/res/drawable/qr_code_background.xml index acbdbd9453..cc1de4926c 100644 --- a/app/src/main/res/drawable/qr_code_background.xml +++ b/app/src/main/res/drawable/qr_code_background.xml @@ -1,6 +1,6 @@ - - + + \ No newline at end of file diff --git a/app/src/main/res/drawable/safety_dot_indicator.xml b/app/src/main/res/drawable/safety_dot_indicator.xml new file mode 100644 index 0000000000..215747bb6f --- /dev/null +++ b/app/src/main/res/drawable/safety_dot_indicator.xml @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/safety_numbers_updating_image.xml b/app/src/main/res/drawable/safety_numbers_updating_image.xml new file mode 100644 index 0000000000..e2b3e4902c --- /dev/null +++ b/app/src/main/res/drawable/safety_numbers_updating_image.xml @@ -0,0 +1,168 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/selected_page_dot_indicator.xml b/app/src/main/res/drawable/selected_page_dot_indicator.xml new file mode 100644 index 0000000000..1e0c1cd96c --- /dev/null +++ b/app/src/main/res/drawable/selected_page_dot_indicator.xml @@ -0,0 +1,16 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/unselected_page_dot_indicator.xml b/app/src/main/res/drawable/unselected_page_dot_indicator.xml new file mode 100644 index 0000000000..3d6eba0572 --- /dev/null +++ b/app/src/main/res/drawable/unselected_page_dot_indicator.xml @@ -0,0 +1,16 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/safety_number_pnp_education_bottom_sheet.xml b/app/src/main/res/layout/safety_number_pnp_education_bottom_sheet.xml new file mode 100644 index 0000000000..0690c8cf46 --- /dev/null +++ b/app/src/main/res/layout/safety_number_pnp_education_bottom_sheet.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/safety_number_qr_page_fragment.xml b/app/src/main/res/layout/safety_number_qr_page_fragment.xml new file mode 100644 index 0000000000..e2bb24dd47 --- /dev/null +++ b/app/src/main/res/layout/safety_number_qr_page_fragment.xml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/safety_number_qr_view.xml b/app/src/main/res/layout/safety_number_qr_view.xml new file mode 100644 index 0000000000..f27d88f643 --- /dev/null +++ b/app/src/main/res/layout/safety_number_qr_view.xml @@ -0,0 +1,227 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/verify_display_fragment.xml b/app/src/main/res/layout/verify_display_fragment.xml index 302c5987a7..e070a8911d 100644 --- a/app/src/main/res/layout/verify_display_fragment.xml +++ b/app/src/main/res/layout/verify_display_fragment.xml @@ -1,7 +1,6 @@ + + + + + + + + + + + android:orientation="vertical"> - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:layout_marginTop="8dp" + app:tabBackground="@drawable/safety_dot_indicator" + app:tabGravity="center" + app:tabPaddingEnd="10dp" + app:tabPaddingStart="10dp" + app:tabIndicatorHeight="0dp"/> #ffA23474 @color/signal_colorPrimary @color/signal_colorPrimary + + #ffEBEAE8 + #ff506ECD diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 1020daea88..1ce1e78625 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -192,7 +192,7 @@ 26dp 48dp - 16dp + 26dp 18dp @@ -220,4 +220,7 @@ 46dp 44dp 32dp + + 24dp + 288dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6e826c07b2..e0e010c43f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2140,6 +2140,12 @@ Signal needs the Camera permission in order to scan a QR code, but it has been permanently denied. Please continue to app settings, select \"Permissions\", and enable \"Camera\". Unable to scan QR code without Camera permission You must first exchange messages in order to view %1$s\'s safety number. + + A safety number will be created with this person after they exchange messages with you. + + OK + + Learn more %1$02d:%2$02d @@ -2702,12 +2708,24 @@ Learn more.]]> + Learn more.]]> Tap to scan Successful match Failed to verify safety number Loading… Mark as verified Clear verification + + Learn more.]]> + + + Changes to safety numbers + + Safety numbers are being updated over a transition period to enable upcoming privacy features in Signal.\n\nTo verify safety numbers, match the color card with your contact’s device. If these don’t match, swipe and try the other pair of safety numbers. Only one pair needs to match. + + Need help? + + Got it Share safety number diff --git a/core-util/src/main/java/org/signal/core/util/BundleExtensions.kt b/core-util/src/main/java/org/signal/core/util/BundleExtensions.kt index 564888cd33..656ebc391a 100644 --- a/core-util/src/main/java/org/signal/core/util/BundleExtensions.kt +++ b/core-util/src/main/java/org/signal/core/util/BundleExtensions.kt @@ -13,6 +13,15 @@ fun Bundle.getParcelableCompat(key: String, clazz: Class): T } } +fun Bundle.requireParcelableCompat(key: String, clazz: Class): T { + return if (Build.VERSION.SDK_INT >= 33) { + this.getParcelable(key, clazz)!! + } else { + @Suppress("DEPRECATION") + this.getParcelable(key)!! + } +} + fun Bundle.getParcelableArrayListCompat(key: String, clazz: Class): ArrayList? { return if (Build.VERSION.SDK_INT >= 33) { this.getParcelableArrayList(key, clazz)