mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-07-07 14:35:29 +01:00
Migrate VerifyDisplayFragment to compose.
This commit is contained in:
committed by
Greyson Parrelli
parent
7fc4ec3006
commit
07329c5b0d
@@ -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
|
||||
}
|
||||
}
|
||||
+106
-24
@@ -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,
|
||||
Reference in New Issue
Block a user