Add key transparency UI.

This commit is contained in:
Michelle Tang
2026-01-27 10:53:28 -05:00
committed by Greyson Parrelli
parent 279f9578cc
commit 69f4c89f84
10 changed files with 373 additions and 74 deletions

View File

@@ -28,6 +28,7 @@ import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap
import androidx.core.widget.ImageViewCompat
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
import com.google.android.material.button.MaterialButton
import org.signal.core.util.dp
import org.signal.libsignal.protocol.fingerprint.Fingerprint
import org.thoughtcrime.securesms.R
@@ -65,6 +66,7 @@ class SafetyNumberQrView : ConstraintLayout {
val qrCodeContainer: View
val shareButton: ImageView
val verifyButton: MaterialButton
private val loading: View
private val qrCode: ImageView
@@ -97,6 +99,7 @@ class SafetyNumberQrView : ConstraintLayout {
)
shareButton = findViewById(R.id.share)
verifyButton = findViewById(R.id.verify_button)
outlineProvider = object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {

View File

@@ -0,0 +1,135 @@
package org.thoughtcrime.securesms.verify
import android.os.Bundle
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentManager
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.horizontalGutters
import org.signal.core.util.getSerializableCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.util.BottomSheetUtil
/**
* Bottom sheet info explaining the results of automatic key verification
*/
class EncryptionVerifiedSheet : ComposeBottomSheetDialogFragment() {
override val peekHeightPercentage: Float = 0.67f
companion object {
private const val ARG_STATUS = "arg.status"
private const val ARG_NAME = "arg.name"
@JvmStatic
fun show(fragmentManager: FragmentManager, status: AutomaticVerificationStatus, name: String) {
EncryptionVerifiedSheet().apply {
arguments = Bundle().apply {
putSerializable(ARG_STATUS, status)
putString(ARG_NAME, name)
}
show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
}
@Composable
override fun SheetContent() {
VerifiedSheet(
verifiedStatus = requireArguments().getSerializableCompat(ARG_STATUS, AutomaticVerificationStatus::class.java)!!,
name = requireArguments().getString(ARG_NAME, "")
) {
this.dismissAllowingStateLoss()
}
}
}
@Composable
fun VerifiedSheet(
verifiedStatus: AutomaticVerificationStatus = AutomaticVerificationStatus.UNAVAILABLE_TEMPORARY,
name: String = "",
onClick: () -> Unit = {}
) {
val (icon, title, body) = when (verifiedStatus) {
AutomaticVerificationStatus.VERIFIED -> {
Triple(
ImageVector.vectorResource(R.drawable.symbol_check_48),
stringResource(R.string.EncryptionVerifiedSheet__title_success),
stringResource(R.string.EncryptionVerifiedSheet__body_success)
)
}
AutomaticVerificationStatus.UNAVAILABLE_PERMANENT -> {
Triple(
ImageVector.vectorResource(R.drawable.symbol_info_48),
stringResource(R.string.EncryptionVerifiedSheet__title_unavailable),
stringResource(R.string.EncryptionVerifiedSheet__body_unavailable)
)
}
else -> {
Triple(
ImageVector.vectorResource(R.drawable.symbol_info_48),
stringResource(R.string.EncryptionVerifiedSheet__title_no_longer_unavailable),
stringResource(R.string.EncryptionVerifiedSheet__body_no_longer_unavailable, name)
)
}
}
return Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.horizontalGutters()
) {
BottomSheets.Handle()
Icon(
imageVector = icon,
contentDescription = null,
tint = Color.Unspecified,
modifier = Modifier.padding(top = 28.dp)
)
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier = Modifier.padding(vertical = 12.dp)
)
Text(
text = body,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium
)
Buttons.LargeTonal(
onClick = onClick,
modifier = Modifier.defaultMinSize(minWidth = 220.dp).padding(vertical = 40.dp)
) {
Text(stringResource(id = android.R.string.ok))
}
}
}
@DayNightPreviews
@Composable
fun FinishedSheetSheetPreview() {
Previews.BottomSheetContentPreview {
VerifiedSheet()
}
}

View File

@@ -4,6 +4,7 @@ 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
@@ -12,9 +13,9 @@ import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.view.ViewTreeObserver.OnScrollChangedListener
import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.core.widget.ImageViewCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
import com.google.android.material.dialog.MaterialAlertDialogBuilder
@@ -32,13 +33,14 @@ import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.visible
import java.nio.charset.StandardCharsets
import java.util.Locale
/**
* Fragment to display a user's identity key.
*/
class VerifyDisplayFragment : Fragment(), OnScrollChangedListener {
class VerifyDisplayFragment : Fragment() {
private lateinit var viewModel: VerifySafetyNumberViewModel
private val binding by ViewBinderDelegate(VerifyDisplayFragmentBinding::bind)
@@ -71,11 +73,19 @@ class VerifyDisplayFragment : Fragment(), OnScrollChangedListener {
updateVerifyButton(requireArguments().getBoolean(VERIFIED_STATE, false), false)
binding.verifyButton.setOnClickListener { updateVerifyButton(!currentVerifiedState, true) }
binding.scrollView.viewTreeObserver?.addOnScrollChangedListener(this)
binding.automaticVerification.visible = RemoteConfig.keyTransparency
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(), R.color.signal_colorPrimary))
viewModel.getAutomaticVerification().observe(viewLifecycleOwner) { status ->
updateStatus(status)
}
viewModel.recipient.observe(this) { recipient: Recipient -> setRecipientText(recipient) }
viewModel.getFingerprint().observe(viewLifecycleOwner) { fingerprint: SafetyNumberFingerprint? ->
if (fingerprint == null) {
@@ -99,6 +109,48 @@ class VerifyDisplayFragment : Fragment(), OnScrollChangedListener {
}
}
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(R.drawable.symbol_info_24)
ImageViewCompat.setImageTintList(binding.autoVerifyIcon, ColorStateList.valueOf(ContextCompat.getColor(requireContext(), R.color.signal_colorOnSurfaceVariant)))
binding.autoVerifyMore.visible = true
}
AutomaticVerificationStatus.UNAVAILABLE_TEMPORARY -> {
binding.autoVerifyText.text = getString(R.string.verify_display_fragment__encryption_unavailable)
binding.autoVerifyIcon.setImageResource(R.drawable.symbol_info_24)
ImageViewCompat.setImageTintList(binding.autoVerifyIcon, ColorStateList.valueOf(ContextCompat.getColor(requireContext(), R.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()!!
@@ -121,7 +173,6 @@ class VerifyDisplayFragment : Fragment(), OnScrollChangedListener {
animateFailure()
}
}
ThreadUtil.postToMain { onScrollChanged() }
}
override fun onCreateContextMenu(
@@ -252,34 +303,15 @@ class VerifyDisplayFragment : Fragment(), OnScrollChangedListener {
private fun updateVerifyButton(verified: Boolean, update: Boolean) {
currentVerifiedState = verified
if (verified) {
binding.verifyButton.setText(R.string.verify_display_fragment__clear_verification)
binding.safetyQrView.verifyButton.setText(R.string.verify_display_fragment__clear_verification)
} else {
binding.verifyButton.setText(R.string.verify_display_fragment__mark_as_verified)
binding.safetyQrView.verifyButton.setText(R.string.verify_display_fragment__mark_as_verified)
}
if (update) {
viewModel.updateSafetyNumberVerification(verified)
}
}
override fun onScrollChanged() {
if (binding.scrollView.canScrollVertically(-1) && currentFingerprint != null) {
if (binding.toolbarShadow.visibility != View.VISIBLE) {
ViewUtil.fadeIn(binding.toolbarShadow, 250)
}
} else {
if (binding.toolbarShadow.visibility != View.GONE) {
ViewUtil.fadeOut(binding.toolbarShadow, 250)
}
}
if (binding.scrollView.canScrollVertically(1)) {
if (binding.verifyIdentityBottomShadow.visibility != View.VISIBLE) {
ViewUtil.fadeIn(binding.verifyIdentityBottomShadow, 250)
}
} else {
ViewUtil.fadeOut(binding.verifyIdentityBottomShadow, 250)
}
}
internal interface Callback {
fun onQrCodeContainerClicked()
}

View File

@@ -39,6 +39,7 @@ class VerifySafetyNumberViewModel(
val recipient: LiveRecipient = Recipient.live(recipientId)
private val fingerprintLiveData = MutableLiveData<SafetyNumberFingerprint?>()
private val automaticVerificationLiveData = MutableLiveData(AutomaticVerificationStatus.NONE)
init {
initializeFingerprints()
@@ -69,6 +70,10 @@ class VerifySafetyNumberViewModel(
return fingerprintLiveData
}
fun getAutomaticVerification(): LiveData<AutomaticVerificationStatus> {
return automaticVerificationLiveData
}
fun updateSafetyNumberVerification(verified: Boolean) {
val recipientId: RecipientId = recipientId
val context: Context = AppDependencies.application
@@ -159,3 +164,11 @@ data class SafetyNumberFingerprint(
return result
}
}
enum class AutomaticVerificationStatus {
NONE,
VERIFYING,
UNAVAILABLE_PERMANENT,
UNAVAILABLE_TEMPORARY,
VERIFIED
}