Migrate VerifyDisplayFragment to compose.

This commit is contained in:
Alex Hart
2026-04-30 14:23:49 -03:00
committed by Greyson Parrelli
parent 7fc4ec3006
commit 07329c5b0d
7 changed files with 720 additions and 446 deletions
@@ -2,59 +2,44 @@ package org.thoughtcrime.securesms.verify
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.content.res.ColorStateList
import android.os.Bundle
import android.text.TextUtils
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.widget.Toast
import androidx.core.content.ContextCompat
import androidx.core.widget.ImageViewCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.signal.core.util.ThreadUtil
import org.signal.core.util.Util
import org.signal.core.util.logging.Log
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.signal.core.ui.compose.ComposeFragment
import org.signal.core.util.requireParcelableCompat
import org.signal.libsignal.protocol.fingerprint.Fingerprint
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ViewBinderDelegate
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.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.visible
import java.nio.charset.StandardCharsets
import java.util.Locale
import org.signal.core.ui.R as CoreUiR
import org.thoughtcrime.securesms.util.CommunicationActions
/**
* Fragment to display a user's identity key.
*/
class VerifyDisplayFragment : Fragment() {
private lateinit var viewModel: VerifySafetyNumberViewModel
class VerifyDisplayFragment : ComposeFragment() {
private val viewModel: VerifyDisplayScreenViewModel by viewModels {
val isVerified = requireArguments().getBoolean(VERIFIED_STATE, false)
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()!!
private val binding by ViewBinderDelegate(VerifyDisplayFragmentBinding::bind)
VerifyDisplayScreenViewModel.Factory(
isSafetyNumberVerified = isVerified,
recipientId = recipientId,
localIdentity = localIdentity,
remoteIdentity = remoteIdentity
)
}
private var callback: Callback? = null
private var animateCodeChanges = true
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) {
@@ -66,145 +51,15 @@ class VerifyDisplayFragment : Fragment() {
}
}
override fun onCreateView(inflater: LayoutInflater, viewGroup: ViewGroup?, bundle: Bundle?): View? {
return ViewUtil.inflate(inflater, viewGroup!!, R.layout.verify_display_fragment)
}
@Composable
override fun FragmentContent() {
val state by viewModel.state.collectAsStateWithLifecycle()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
initializeViewModel()
if (RemoteConfig.internalUser && SignalStore.settings.automaticVerificationEnabled && !SignalStore.uiHints.hasSeenVerifyAutomaticallySheet()) {
VerifyAutomaticallyEducationSheet.show(parentFragmentManager)
}
updateVerifyButton(requireArguments().getBoolean(VERIFIED_STATE, false), false)
binding.automaticVerification.visible = RemoteConfig.internalUser && SignalStore.settings.automaticVerificationEnabled
binding.safetyQrView.verifyButton.setOnClickListener { updateVerifyButton(!currentVerifiedState, true) }
binding.toolbar.setNavigationOnClickListener { requireActivity().onBackPressed() }
binding.toolbar.setTitle(R.string.AndroidManifest__verify_safety_number)
binding.caption.text = getString(R.string.verify_display_fragment__auto_verify_not_available)
binding.caption.setLink(getString(R.string.verify_display_fragment__link))
binding.caption.setLinkColor(ContextCompat.getColor(requireContext(), CoreUiR.color.signal_colorPrimary))
viewModel.getAutomaticVerification().observe(viewLifecycleOwner) { status ->
if (status == AutomaticVerificationStatus.NONE) {
binding.autoVerifyContainer.setOnClickListener {
viewModel.verifyAutomatically()
}
} else {
binding.autoVerifyContainer.setOnClickListener(null)
}
animateStatus(status)
}
viewModel.recipient.observe(this) { recipient: Recipient -> setRecipientText(recipient) }
viewModel.getFingerprint().observe(viewLifecycleOwner) { fingerprint: SafetyNumberFingerprint? ->
if (fingerprint == null) {
val resolved = viewModel.recipient.resolve()
Log.w(TAG, String.format(Locale.ENGLISH, "Could not show proper verification! verifyV2: %s, hasUuid: %s, hasE164: %s", RemoteConfig.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
}
binding.safetyQrView.setFingerprintViews(fingerprint.fingerprint, animateCodeChanges)
binding.safetyQrView.shareButton.setOnClickListener { v: View? -> handleShare(fingerprint.fingerprint) }
binding.safetyQrView.qrCodeContainer.setOnClickListener { v: View? -> callback!!.onQrCodeContainerClicked() }
registerForContextMenu(binding.safetyQrView.numbersContainer)
}
}
private fun animateStatus(status: AutomaticVerificationStatus) {
if (status == AutomaticVerificationStatus.NONE || status == AutomaticVerificationStatus.UNAVAILABLE_PERMANENT) {
updateStatus(status)
} else {
binding.autoVerifyContainer.animate()
.alpha(0f)
.setDuration(FADE_TIME)
.withEndAction {
updateStatus(status)
binding.autoVerifyContainer.animate()
.alpha(1f)
.setDuration(FADE_TIME)
.start()
}
.start()
}
}
private fun updateStatus(status: AutomaticVerificationStatus) {
when (status) {
AutomaticVerificationStatus.NONE -> {
binding.autoVerifyText.text = getString(R.string.verify_display_fragment__verify_automatic)
binding.autoVerifyIcon.setImageResource(R.drawable.symbol_key_24)
binding.autoVerifyIcon.imageTintList = null
binding.autoVerifyMore.visible = false
}
AutomaticVerificationStatus.VERIFYING -> {
binding.autoVerifyText.text = getString(R.string.verify_display_fragment__verifying)
binding.autoVerifyMore.visible = false
}
AutomaticVerificationStatus.UNAVAILABLE_PERMANENT -> {
binding.autoVerifyText.text = getString(R.string.verify_display_fragment__encryption_unavailable)
binding.autoVerifyIcon.setImageResource(CoreUiR.drawable.symbol_info_24)
ImageViewCompat.setImageTintList(binding.autoVerifyIcon, ColorStateList.valueOf(ContextCompat.getColor(requireContext(), CoreUiR.color.signal_colorOnSurfaceVariant)))
binding.autoVerifyMore.visible = true
}
AutomaticVerificationStatus.UNAVAILABLE_TEMPORARY -> {
binding.autoVerifyText.text = getString(R.string.verify_display_fragment__encryption_unavailable)
binding.autoVerifyIcon.setImageResource(CoreUiR.drawable.symbol_info_24)
ImageViewCompat.setImageTintList(binding.autoVerifyIcon, ColorStateList.valueOf(ContextCompat.getColor(requireContext(), CoreUiR.color.signal_colorOnSurfaceVariant)))
binding.autoVerifyMore.visible = true
}
AutomaticVerificationStatus.VERIFIED -> {
binding.autoVerifyText.text = getString(R.string.verify_display_fragment__encryption_verified)
binding.autoVerifyIcon.setImageResource(R.drawable.symbol_check_filled_circle_24)
binding.autoVerifyIcon.imageTintList = null
binding.autoVerifyMore.visible = true
}
}
if (status == AutomaticVerificationStatus.VERIFYING) {
binding.autoVerifySpinner.visible = true
binding.autoVerifyIcon.visible = false
} else {
binding.autoVerifySpinner.visible = false
binding.autoVerifyIcon.visible = true
}
binding.autoVerifyMore.setOnClickListener { EncryptionVerifiedSheet.show(parentFragmentManager, status, viewModel.recipient.resolve().getDisplayName(requireContext())) }
}
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 onResume() {
super.onResume()
setRecipientText(viewModel.recipient.get())
if (animateSuccessOnDraw) {
animateSuccessOnDraw = false
ThreadUtil.postToMain {
animateSuccess()
}
} else if (animateFailureOnDraw) {
animateFailureOnDraw = false
ThreadUtil.postToMain {
animateFailure()
}
}
VerifyDisplayScreen(
state = state,
emitter = this::onEvent,
onQrViewInitialized = this::onQrViewInitialised
)
}
override fun onCreateContextMenu(
@@ -213,7 +68,7 @@ class VerifyDisplayFragment : Fragment() {
menuInfo: ContextMenuInfo?
) {
super.onCreateContextMenu(menu, view, menuInfo)
val fingerprint = viewModel.getFingerprint().value
val fingerprint = viewModel.fingerprintSnapshot
if (fingerprint != null) {
val inflater = requireActivity().menuInflater
inflater.inflate(R.menu.verify_display_fragment_context_menu, menu)
@@ -221,126 +76,47 @@ class VerifyDisplayFragment : Fragment() {
}
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)
if (viewModel.fingerprintSnapshot == null) return super.onContextItemSelected(item)
return when (item.itemId) {
R.id.menu_copy -> {
viewModel.copyFingerprintToClipboard()
true
}
R.id.menu_compare -> {
viewModel.compareClipboardToFingerprint()
true
}
else -> {
super.onContextItemSelected(item)
}
}
}
private val currentFingerprint: Fingerprint?
get() {
return viewModel.getFingerprint().value?.fingerprint
}
fun setScannedFingerprint(scanned: String) {
animateCodeChanges = false
currentFingerprint?.let {
try {
if (currentFingerprint!!.scannableFingerprint.compareTo(scanned.toByteArray(StandardCharsets.ISO_8859_1))) {
animateSuccessOnDraw = true
} else {
animateFailureOnDraw = true
}
} catch (e: Exception) {
Log.w(TAG, e)
showAlertDialog(R.string.VerifyIdentityActivity_the_scanned_qr_code_is_not_a_correctly_formatted_safety_number)
animateFailureOnDraw = true
return
}
}
viewModel.setScannedFingerprint(scanned)
}
private fun showAlertDialog(stringResId: Int) {
MaterialAlertDialogBuilder(requireContext())
.setMessage(stringResId)
.setPositiveButton(R.string.VerifyDisplayFragment__scan_result_dialog_ok, null)
.show()
}
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) {
showAlertDialog(R.string.VerifyIdentityActivity_no_safety_number_to_compare_was_found_in_the_clipboard)
return
}
val numericClipboardData = clipboardData.replace("\\D".toRegex(), "")
if (TextUtils.isEmpty(numericClipboardData) || numericClipboardData.length != 60) {
showAlertDialog(R.string.VerifyIdentityActivity_no_safety_number_to_compare_was_found_in_the_clipboard)
return
}
if (currentFingerprint?.displayableFingerprint?.displayText == numericClipboardData) {
animateSuccess()
} else {
animateFailure()
}
}
private fun animateSuccess() {
animateCodeChanges = false
binding.safetyQrView.animateVerifiedSuccess()
}
private fun animateFailure() {
animateCodeChanges = false
binding.safetyQrView.animateVerifiedFailure()
}
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)
}
private fun handleShare() {
try {
startActivity(Intent.createChooser(intent, getString(R.string.VerifyIdentityActivity_share_safety_number_via)))
startActivity(Intent.createChooser(viewModel.createShareIntent(requireActivity()), 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) {
binding.description.text = getString(R.string.verify_display_fragment__pnp_verify_safety_numbers_explanation_with_s, recipient.getDisplayName(requireContext()))
binding.description.setLink("https://signal.org/redirect/safety-numbers")
binding.description.setLinkColor(ContextCompat.getColor(requireContext(), CoreUiR.color.signal_colorPrimary))
private fun onQrViewInitialised(view: View) {
registerForContextMenu(view)
}
private fun updateVerifyButton(verified: Boolean, update: Boolean) {
currentVerifiedState = verified
if (verified) {
binding.safetyQrView.verifyButton.setText(R.string.verify_display_fragment__clear_verification)
} else {
binding.safetyQrView.verifyButton.setText(R.string.verify_display_fragment__mark_as_verified)
}
if (update) {
viewModel.updateSafetyNumberVerification(verified)
private fun onEvent(event: VerifyDisplayScreenEvent) {
when (event) {
VerifyDisplayScreenEvent.EducationDismiss -> viewModel.setSeenEducationSheet()
VerifyDisplayScreenEvent.EducationLearnMoreClick -> CommunicationActions.openBrowserLink(requireContext(), getString(R.string.verify_display_fragment__link))
VerifyDisplayScreenEvent.QrClick -> callback?.onQrCodeContainerClicked()
VerifyDisplayScreenEvent.ShareClick -> handleShare()
VerifyDisplayScreenEvent.VerifyAutomaticallyClick -> viewModel.verifyAutomatically()
is VerifyDisplayScreenEvent.VerifyButtonClick -> viewModel.updateSafetyNumberVerification(event.isVerified)
VerifyDisplayScreenEvent.YouMustFirstExchangeMessagesDialogDismiss -> requireActivity().finish()
}
}
@@ -349,14 +125,11 @@ class VerifyDisplayFragment : Fragment() {
}
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"
private const val FADE_TIME = 250L
fun create(
recipientId: RecipientId,
@@ -0,0 +1,51 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.verify
import android.content.Context
import android.content.Intent
import androidx.annotation.UiContext
import org.signal.core.util.Util
import org.signal.libsignal.protocol.fingerprint.Fingerprint
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.verify.SafetyNumberQrView.Companion.getSegments
import org.thoughtcrime.securesms.dependencies.AppDependencies
object VerifyDisplayRepository {
fun writeToClipboard(fingerprint: Fingerprint) {
Util.writeTextToClipboard(AppDependencies.application, "Safety numbers", getFormattedSafetyNumbers(fingerprint))
}
fun readFromClipboard(): String? {
return Util.readTextFromClipboard(AppDependencies.application)
}
@UiContext
fun createShareIntent(context: Context, fingerprint: Fingerprint): Intent {
val shareString = """
${context.getString(R.string.VerifyIdentityActivity_our_signal_safety_number)}
${getFormattedSafetyNumbers(fingerprint)}
""".trimIndent()
return Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, shareString)
}
}
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()
}
}
@@ -0,0 +1,454 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.verify
import android.view.View
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.annotation.StringRes
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withLink
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.IconButtons.IconButton
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.horizontalGutters
import org.signal.core.ui.compose.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.verify.SafetyNumberQrView
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.rememberRecipientField
import org.thoughtcrime.securesms.util.CommunicationActions
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VerifyDisplayScreen(
state: VerifyDisplayScreenState,
emitter: (VerifyDisplayScreenEvent) -> Unit,
onQrViewInitialized: (View) -> Unit
) {
state.recipient ?: return
val context = LocalContext.current
val backPressedDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
val displayName by rememberRecipientField(state.recipient) { getDisplayName(context) }
Scaffolds.Settings(
title = stringResource(R.string.AndroidManifest__verify_safety_number),
navigationIcon = SignalIcons.ArrowStart.imageVector,
onNavigationClick = { backPressedDispatcher?.onBackPressed() }
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(it)
) {
SafetyNumberQr(
state = state,
emitter = emitter,
onQrViewInitialized = onQrViewInitialized,
modifier = Modifier.padding(top = 24.dp)
)
Text(
text = buildAnnotatedString {
append(stringResource(R.string.verify_display_fragment__pnp_verify_safety_numbers_explanation_with_s, displayName))
append(" ")
withLink(
link = LinkAnnotation.Clickable(
tag = "verify-learn-more",
styles = TextLinkStyles(
style = SpanStyle(
color = MaterialTheme.colorScheme.primary
)
)
) {
CommunicationActions.openBrowserLink(context, "https://signal.org/redirect/safety-numbers")
}
) {
append(stringResource(R.string.LearnMoreTextView_learn_more))
}
},
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(
start = 32.dp,
end = 32.dp,
top = 24.dp,
bottom = 20.dp
)
)
AutomaticKeyVerificationBlock(
state = state,
emitter = emitter
)
Text(
text = buildAnnotatedString {
append(stringResource(R.string.verify_display_fragment__auto_verify_not_available))
append(" ")
val url = stringResource(R.string.verify_display_fragment__link)
withLink(
link = LinkAnnotation.Clickable(
tag = "auto-verify-learn-more",
styles = TextLinkStyles(
style = SpanStyle(
color = MaterialTheme.colorScheme.primary
)
)
) {
CommunicationActions.openBrowserLink(context, url)
}
) {
append(stringResource(R.string.LearnMoreTextView_learn_more))
}
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 12.dp)
)
}
}
if (state.shouldDisplayVerifyAutomaticallyEducationSheet) {
ModalBottomSheet(
dragHandle = null,
onDismissRequest = {
emitter(VerifyDisplayScreenEvent.EducationDismiss)
}
) {
VerifyEducationSheet(
onVerify = {
emitter(VerifyDisplayScreenEvent.EducationDismiss)
},
onLearnMore = {
emitter(VerifyDisplayScreenEvent.EducationLearnMoreClick)
}
)
}
}
}
@Composable
private fun SafetyNumberQr(
state: VerifyDisplayScreenState,
onQrViewInitialized: (View) -> Unit,
emitter: (VerifyDisplayScreenEvent) -> Unit,
modifier: Modifier = Modifier
) {
val fingerprint = (state.fingerprintHolder as? FingerprintHolder.Initialised)?.fingerprint
var animateSuccess by remember { mutableStateOf(false) }
var animateFailure by remember { mutableStateOf(false) }
AndroidView(
factory = { SafetyNumberQrView(it) },
modifier = modifier
) {
// TODO - qrCodeContainer has a context menu.
// TODO - animateVerifiedSuccess // animateVerifiedFailure
if (fingerprint != null) {
it.setFingerprintViews(fingerprint.fingerprint, true) // TODO - animateCodeChanges
}
if (animateSuccess) {
animateSuccess = false
it.animateVerifiedSuccess()
}
if (animateFailure) {
animateFailure = false
it.animateVerifiedFailure()
}
it.verifyButton.setOnClickListener {
emitter(VerifyDisplayScreenEvent.VerifyButtonClick(!state.isSafetyNumberVerified))
}
it.shareButton.setOnClickListener {
emitter(VerifyDisplayScreenEvent.ShareClick)
}
it.qrCodeContainer.setOnClickListener {
emitter(VerifyDisplayScreenEvent.QrClick)
}
onQrViewInitialized(it.numbersContainer)
if (state.isSafetyNumberVerified) {
it.verifyButton.setText(R.string.verify_display_fragment__clear_verification)
} else {
it.verifyButton.setText(R.string.verify_display_fragment__mark_as_verified)
}
}
if (state.fingerprintHolder == FingerprintHolder.NoFingerprintAvailable) {
YouMustFirstExchangeMessagesDialog(state, emitter)
}
var alertMessage by remember { mutableStateOf(-1) }
LaunchedEffect(state.clipComparisonResult?.submissionTime, state.scanComparisonResult?.submissionTime) {
when (state.clipComparisonResult) {
is VerifyDisplayScreenState.ClipComparisonResult.Failure -> animateFailure = true
is VerifyDisplayScreenState.ClipComparisonResult.NoDataInClipboard -> {
alertMessage = R.string.VerifyIdentityActivity_no_safety_number_to_compare_was_found_in_the_clipboard
}
is VerifyDisplayScreenState.ClipComparisonResult.NoSafetyNumberInClipboard -> {
alertMessage = R.string.VerifyIdentityActivity_no_safety_number_to_compare_was_found_in_the_clipboard
}
is VerifyDisplayScreenState.ClipComparisonResult.Success -> animateSuccess = true
null -> Unit
}
when (state.scanComparisonResult) {
is VerifyDisplayScreenState.ScanComparisonResult.Failure -> animateFailure = true
is VerifyDisplayScreenState.ScanComparisonResult.IncorrectFormat -> {
alertMessage = R.string.VerifyIdentityActivity_the_scanned_qr_code_is_not_a_correctly_formatted_safety_number
}
is VerifyDisplayScreenState.ScanComparisonResult.Success -> animateSuccess = true
null -> Unit
}
}
if (alertMessage > 0) {
Dialogs.SimpleMessageDialog(
message = stringResource(alertMessage),
dismiss = stringResource(android.R.string.ok),
onDismiss = {
alertMessage = -1
}
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AutomaticKeyVerificationBlock(
state: VerifyDisplayScreenState,
emitter: (VerifyDisplayScreenEvent) -> Unit
) {
var displayEncryptedSheet by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxWidth()
.horizontalGutters()
) {
Text(
text = stringResource(R.string.verify_display_fragment__automatic),
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(vertical = 12.dp)
)
AnimatedContent(
targetState = state.automaticVerificationStatus,
modifier = Modifier
.fillMaxWidth()
.background(color = SignalTheme.colors.colorSurface1, shape = RoundedCornerShape(38.dp))
.padding(horizontal = 16.dp)
) { targetStatus ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable(enabled = targetStatus == AutomaticVerificationStatus.NONE, onClick = {
emitter(VerifyDisplayScreenEvent.VerifyAutomaticallyClick)
}, role = Role.Button)
) {
if (targetStatus == AutomaticVerificationStatus.VERIFYING) {
CircularProgressIndicator(
modifier = Modifier
.padding(vertical = 16.dp)
.size(24.dp),
color = MaterialTheme.colorScheme.outline,
strokeCap = StrokeCap.Square,
strokeWidth = 2.dp
)
} else {
StatusIcon(targetStatus)
}
Text(
text = stringResource(getStatusText(targetStatus)),
modifier = Modifier
.padding(vertical = 16.dp)
.padding(start = 12.dp)
)
if (targetStatus != AutomaticVerificationStatus.NONE && targetStatus != AutomaticVerificationStatus.VERIFYING) {
IconButton(onClick = {
displayEncryptedSheet = true
}) {
Icon(
imageVector = SignalIcons.ChevronRight.imageVector,
contentDescription = null,
tint = MaterialTheme.colorScheme.outline,
modifier = Modifier
.padding(start = 2.dp)
.size(16.dp)
)
}
}
}
}
}
if (displayEncryptedSheet) {
state.recipient ?: return
val context = LocalContext.current
val name by rememberRecipientField(state.recipient) { getDisplayName(context) }
ModalBottomSheet(
dragHandle = null,
onDismissRequest = { displayEncryptedSheet = false }
) {
VerifiedSheet(
verifiedStatus = state.automaticVerificationStatus,
name = name,
onClick = {
displayEncryptedSheet = false
}
)
}
}
}
@Composable
private fun YouMustFirstExchangeMessagesDialog(
state: VerifyDisplayScreenState,
emitter: (VerifyDisplayScreenEvent) -> Unit
) {
state.recipient ?: return
val context = LocalContext.current
val recipientName = rememberRecipientField(state.recipient) { getDisplayName(context) }
Dialogs.SimpleMessageDialog(
message = stringResource(R.string.VerifyIdentityActivity_you_must_first_exchange_messages_in_order_to_view, recipientName),
dismiss = stringResource(android.R.string.ok),
onDismiss = {
emitter(VerifyDisplayScreenEvent.YouMustFirstExchangeMessagesDialogDismiss)
}
)
}
@StringRes
private fun getStatusText(status: AutomaticVerificationStatus): Int {
return when (status) {
AutomaticVerificationStatus.NONE -> R.string.verify_display_fragment__verify_automatic
AutomaticVerificationStatus.VERIFYING -> R.string.verify_display_fragment__verifying
AutomaticVerificationStatus.UNAVAILABLE_PERMANENT -> R.string.verify_display_fragment__encryption_unavailable
AutomaticVerificationStatus.UNAVAILABLE_TEMPORARY -> R.string.verify_display_fragment__encryption_unavailable
AutomaticVerificationStatus.VERIFIED -> R.string.verify_display_fragment__encryption_verified
}
}
@Composable
private fun StatusIcon(status: AutomaticVerificationStatus) {
if (status == AutomaticVerificationStatus.VERIFYING) {
return
}
val icon = when (status) {
AutomaticVerificationStatus.NONE -> ImageVector.vectorResource(R.drawable.symbol_key_24)
AutomaticVerificationStatus.VERIFYING -> error("None.")
AutomaticVerificationStatus.UNAVAILABLE_PERMANENT -> SignalIcons.Info.imageVector
AutomaticVerificationStatus.UNAVAILABLE_TEMPORARY -> SignalIcons.Info.imageVector
AutomaticVerificationStatus.VERIFIED -> ImageVector.vectorResource(R.drawable.symbol_check_filled_circle_24)
}
val tint = when (status) {
AutomaticVerificationStatus.NONE -> MaterialTheme.colorScheme.onSurface
AutomaticVerificationStatus.VERIFYING -> error("None.")
AutomaticVerificationStatus.UNAVAILABLE_PERMANENT -> MaterialTheme.colorScheme.onSurfaceVariant
AutomaticVerificationStatus.UNAVAILABLE_TEMPORARY -> MaterialTheme.colorScheme.onSurfaceVariant
AutomaticVerificationStatus.VERIFIED -> Color(0xFF4CAF50)
}
Icon(
imageVector = icon,
tint = tint,
contentDescription = null
)
}
@DayNightPreviews
@Composable
private fun VerifyDisplayScreenPreview() {
Previews.Preview {
var state by remember {
mutableStateOf(
VerifyDisplayScreenState(
isSafetyNumberVerified = false,
isAutomaticVerificationVisible = true,
shouldDisplayVerifyAutomaticallyEducationSheet = false,
recipient = Recipient(
isResolving = false,
systemContactName = "Miles Morales"
)
)
)
}
VerifyDisplayScreen(
state = state,
emitter = {
state = when (it) {
VerifyDisplayScreenEvent.VerifyAutomaticallyClick -> {
state.copy(automaticVerificationStatus = AutomaticVerificationStatus.VERIFYING)
}
is VerifyDisplayScreenEvent.VerifyButtonClick -> {
state.copy(isSafetyNumberVerified = it.isVerified)
}
else -> state
}
},
onQrViewInitialized = {}
)
}
}
@@ -0,0 +1,16 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.verify
sealed interface VerifyDisplayScreenEvent {
data class VerifyButtonClick(val isVerified: Boolean) : VerifyDisplayScreenEvent
data object VerifyAutomaticallyClick : VerifyDisplayScreenEvent
data object ShareClick : VerifyDisplayScreenEvent
data object QrClick : VerifyDisplayScreenEvent
data object YouMustFirstExchangeMessagesDialogDismiss : VerifyDisplayScreenEvent
data object EducationDismiss : VerifyDisplayScreenEvent
data object EducationLearnMoreClick : VerifyDisplayScreenEvent
}
@@ -0,0 +1,38 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.verify
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.RemoteConfig
data class VerifyDisplayScreenState(
val isSafetyNumberVerified: Boolean,
val isAutomaticVerificationVisible: Boolean = RemoteConfig.internalUser && SignalStore.settings.automaticVerificationEnabled,
val shouldDisplayVerifyAutomaticallyEducationSheet: Boolean = RemoteConfig.internalUser && SignalStore.settings.automaticVerificationEnabled && !SignalStore.uiHints.hasSeenVerifyAutomaticallySheet(),
val recipient: Recipient? = null,
val fingerprintHolder: FingerprintHolder = FingerprintHolder.Uninitialised,
val automaticVerificationStatus: AutomaticVerificationStatus = AutomaticVerificationStatus.NONE,
val clipComparisonResult: ClipComparisonResult? = null,
val scanComparisonResult: ScanComparisonResult? = null
) {
sealed interface ClipComparisonResult {
val submissionTime: Long
data class NoDataInClipboard(override val submissionTime: Long = System.currentTimeMillis()) : ClipComparisonResult
data class NoSafetyNumberInClipboard(override val submissionTime: Long = System.currentTimeMillis()) : ClipComparisonResult
data class Success(override val submissionTime: Long = System.currentTimeMillis()) : ClipComparisonResult
data class Failure(override val submissionTime: Long = System.currentTimeMillis()) : ClipComparisonResult
}
sealed interface ScanComparisonResult {
val submissionTime: Long
data class IncorrectFormat(override val submissionTime: Long = System.currentTimeMillis()) : ScanComparisonResult
data class Success(override val submissionTime: Long = System.currentTimeMillis()) : ScanComparisonResult
data class Failure(override val submissionTime: Long = System.currentTimeMillis()) : ScanComparisonResult
}
}
@@ -6,14 +6,20 @@
package org.thoughtcrime.securesms.verify
import android.content.Context
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import android.content.Intent
import android.text.TextUtils
import androidx.annotation.UiContext
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.asFlow
import org.signal.core.util.concurrent.SignalDispatchers
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
@@ -32,25 +38,45 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.IdentityUtil
import java.nio.charset.StandardCharsets
class VerifySafetyNumberViewModel(
class VerifyDisplayScreenViewModel(
private val isSafetyNumberVerified: Boolean,
private val recipientId: RecipientId,
private val localIdentity: IdentityKey,
private val remoteIdentity: IdentityKey
) : ViewModel() {
companion object {
val TAG = Log.tag(VerifySafetyNumberViewModel::class.java)
val TAG = Log.tag(VerifyDisplayScreenViewModel::class.java)
}
val recipient: LiveRecipient = Recipient.live(recipientId)
private val recipient: LiveRecipient = Recipient.live(recipientId)
private val fingerprintLiveData = MutableLiveData<SafetyNumberFingerprint?>()
private val automaticVerificationLiveData = MutableLiveData(AutomaticVerificationStatus.NONE)
private val internalState = MutableStateFlow(
VerifyDisplayScreenState(
isSafetyNumberVerified = isSafetyNumberVerified
)
)
val state: StateFlow<VerifyDisplayScreenState> = internalState
val fingerprintSnapshot: Fingerprint?
get() {
return when (val holder = state.value.fingerprintHolder) {
is FingerprintHolder.Initialised -> holder.fingerprint.fingerprint
else -> null
}
}
init {
initializeFingerprints()
checkAutomaticVerificationEligibility()
viewModelScope.launch {
recipient.observable().asFlow().collectLatest { r ->
internalState.update { it.copy(recipient = r) }
}
}
}
private fun checkAutomaticVerificationEligibility() {
@@ -59,31 +85,85 @@ class VerifySafetyNumberViewModel(
ProfileKeyUtil.profileKeyOrNull(recipient.get().profileKey) == null ||
SignalStore.misc.hasKeyTransparencyFailure
) {
automaticVerificationLiveData.postValue(AutomaticVerificationStatus.UNAVAILABLE_PERMANENT)
internalState.update { it.copy(automaticVerificationStatus = AutomaticVerificationStatus.UNAVAILABLE_PERMANENT) }
}
}
fun setSeenEducationSheet() {
SignalStore.uiHints.setSeenVerifyAutomaticallySheet()
internalState.update { it.copy(shouldDisplayVerifyAutomaticallyEducationSheet = false) }
}
fun setScannedFingerprint(scanned: String) {
fingerprintSnapshot?.let { fingerprint ->
try {
if (fingerprint.scannableFingerprint.compareTo(scanned.toByteArray(StandardCharsets.ISO_8859_1))) {
internalState.update { it.copy(scanComparisonResult = VerifyDisplayScreenState.ScanComparisonResult.Success()) }
} else {
internalState.update { it.copy(scanComparisonResult = VerifyDisplayScreenState.ScanComparisonResult.Failure()) }
}
} catch (e: Exception) {
Log.w(TAG, e)
internalState.update { it.copy(scanComparisonResult = VerifyDisplayScreenState.ScanComparisonResult.IncorrectFormat()) }
return
}
}
}
@UiContext
fun createShareIntent(context: Context): Intent {
return VerifyDisplayRepository.createShareIntent(context, fingerprintSnapshot!!)
}
fun copyFingerprintToClipboard() {
VerifyDisplayRepository.writeToClipboard(fingerprintSnapshot!!)
}
fun compareClipboardToFingerprint() {
val clipboardData = VerifyDisplayRepository.readFromClipboard()
val fingerprint = (state.value.fingerprintHolder as? FingerprintHolder.Initialised)?.fingerprint
if (clipboardData == null) {
internalState.update { it.copy(clipComparisonResult = VerifyDisplayScreenState.ClipComparisonResult.NoDataInClipboard()) }
return
}
val numericClipboardData = clipboardData.replace("\\D".toRegex(), "")
if (TextUtils.isEmpty(numericClipboardData) || numericClipboardData.length != 60) {
internalState.update { it.copy(clipComparisonResult = VerifyDisplayScreenState.ClipComparisonResult.NoSafetyNumberInClipboard()) }
return
}
if (fingerprint?.fingerprint?.displayableFingerprint?.displayText == numericClipboardData) {
internalState.update { it.copy(clipComparisonResult = VerifyDisplayScreenState.ClipComparisonResult.Success()) }
} else {
internalState.update { it.copy(clipComparisonResult = VerifyDisplayScreenState.ClipComparisonResult.Failure()) }
}
}
fun verifyAutomatically(canRetry: Boolean = true) {
viewModelScope.launch(SignalDispatchers.IO) {
if (automaticVerificationLiveData.value == AutomaticVerificationStatus.UNAVAILABLE_PERMANENT || !isActive) {
if (internalState.value.automaticVerificationStatus == AutomaticVerificationStatus.UNAVAILABLE_PERMANENT || !isActive) {
return@launch
}
automaticVerificationLiveData.postValue(AutomaticVerificationStatus.VERIFYING)
internalState.update { it.copy(automaticVerificationStatus = AutomaticVerificationStatus.VERIFYING) }
when (val result = VerifySafetyNumberRepository.verifyAutomatically(recipient.get())) {
VerifySafetyNumberRepository.VerifyResult.Success -> {
automaticVerificationLiveData.postValue(AutomaticVerificationStatus.VERIFIED)
internalState.update { it.copy(automaticVerificationStatus = AutomaticVerificationStatus.VERIFIED) }
}
is VerifySafetyNumberRepository.VerifyResult.RetryableFailure -> {
if (canRetry) {
delay(result.duration.toMillis())
verifyAutomatically(canRetry = false)
} else {
Log.i(TAG, "Got a retryable exception, but we already retried once. Ignoring.")
automaticVerificationLiveData.postValue(AutomaticVerificationStatus.UNAVAILABLE_TEMPORARY)
internalState.update { it.copy(automaticVerificationStatus = AutomaticVerificationStatus.UNAVAILABLE_TEMPORARY) }
}
}
VerifySafetyNumberRepository.VerifyResult.CorruptedFailure -> {
Log.w(TAG, "KT store was corrupted. Clearing everything and starting again.")
SignalStore.account.distinguishedHead = null
@@ -92,11 +172,12 @@ class VerifySafetyNumberViewModel(
verifyAutomatically(canRetry = false)
} else {
Log.i(TAG, "Store was corrupted and we can retry, but we already retried once. Ignoring.")
automaticVerificationLiveData.postValue(AutomaticVerificationStatus.UNAVAILABLE_TEMPORARY)
internalState.update { it.copy(automaticVerificationStatus = AutomaticVerificationStatus.UNAVAILABLE_TEMPORARY) }
}
}
VerifySafetyNumberRepository.VerifyResult.UnretryableFailure -> {
automaticVerificationLiveData.postValue(AutomaticVerificationStatus.UNAVAILABLE_TEMPORARY)
internalState.update { it.copy(automaticVerificationStatus = AutomaticVerificationStatus.UNAVAILABLE_TEMPORARY) }
}
}
}
@@ -117,19 +198,13 @@ class VerifySafetyNumberViewModel(
aciFingerprint = SafetyNumberFingerprint(version, localIdentifier, localIdentity, remoteIdentifier, remoteIdentity, generator.createFor(version, localIdentifier, localIdentity, remoteIdentifier, remoteIdentity))
}
fingerprintLiveData.postValue(aciFingerprint)
internalState.update { state -> state.copy(fingerprintHolder = aciFingerprint?.let { FingerprintHolder.Initialised(it) } ?: FingerprintHolder.NoFingerprintAvailable) }
}
}
fun getFingerprint(): LiveData<SafetyNumberFingerprint?> {
return fingerprintLiveData
}
fun getAutomaticVerification(): LiveData<AutomaticVerificationStatus> {
return automaticVerificationLiveData
}
fun updateSafetyNumberVerification(verified: Boolean) {
internalState.update { it.copy(isSafetyNumberVerified = verified) }
val recipientId: RecipientId = recipientId
val context: Context = AppDependencies.application
@@ -171,12 +246,13 @@ class VerifySafetyNumberViewModel(
}
class Factory(
private val isSafetyNumberVerified: Boolean,
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))!!
return modelClass.cast(VerifyDisplayScreenViewModel(isSafetyNumberVerified, recipientId, localIdentity, remoteIdentity))!!
}
}
}
@@ -226,6 +302,12 @@ data class SafetyNumberFingerprint(
}
}
sealed interface FingerprintHolder {
data object Uninitialised : FingerprintHolder
data object NoFingerprintAvailable : FingerprintHolder
data class Initialised(val fingerprint: SafetyNumberFingerprint) : FingerprintHolder
}
enum class AutomaticVerificationStatus {
NONE,
VERIFYING,