mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-07-07 06:25:16 +01:00
Present the backup key as a passphrase.
This commit is contained in:
committed by
jeffrey-signal
parent
66b6c1656c
commit
5c64a91864
+85
@@ -0,0 +1,85 @@
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.subscription
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
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 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.thoughtcrime.securesms.R
|
||||
|
||||
/**
|
||||
* Bottom sheet shown when confirming your recovery key after saving to password manager
|
||||
*/
|
||||
@Composable
|
||||
fun ConfirmRecoveryKeySheet(
|
||||
onConfirm: () -> Unit = {},
|
||||
onSeeAgain: () -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.horizontalGutters(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.backup_confirm_80),
|
||||
tint = Color.Unspecified,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(top = 24.dp, bottom = 16.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.MessageBackupsKeyVerifyScreen__confirm_your_backup_key),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.MessageBackupsKeyRecordScreen__confirm_that_your_recovery),
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.size(60.dp))
|
||||
|
||||
Buttons.LargeTonal(onClick = onConfirm) {
|
||||
Text(text = stringResource(R.string.MessageBackupsKeyRecordScreen__confirm_recovery))
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = onSeeAgain,
|
||||
modifier = Modifier.padding(vertical = 16.dp)
|
||||
) {
|
||||
Text(text = stringResource(R.string.MessageBackupsKeyRecordScreen__see_key_again))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun ConfirmRecoveryKeyPreview() {
|
||||
Previews.BottomSheetContentPreview {
|
||||
ConfirmRecoveryKeySheet(
|
||||
onConfirm = {},
|
||||
onSeeAgain = {},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
+28
-1
@@ -68,7 +68,8 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
|
||||
private val viewModel: MessageBackupsFlowViewModel by viewModel {
|
||||
MessageBackupsFlowViewModel(
|
||||
initialTierSelection = requireArguments().getSerializableCompat(TIER, MessageBackupTier::class.java),
|
||||
googlePlayApiAvailability = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(requireContext())
|
||||
googlePlayApiAvailability = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(requireContext()),
|
||||
isCredentialManagerSupported = AndroidCredentialRepository.isCredentialManagerSupported(requireContext())
|
||||
)
|
||||
}
|
||||
|
||||
@@ -155,6 +156,32 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
|
||||
val context = LocalContext.current
|
||||
val passwordManagerSettingsIntent = AndroidCredentialRepository.getCredentialManagerSettingsIntent(requireContext())
|
||||
|
||||
MessageBackupsKeyRecordScreen(
|
||||
backupKey = state.accountEntropyPool.displayValue,
|
||||
keySaveState = state.backupKeySaveState,
|
||||
canOpenPasswordManagerSettings = passwordManagerSettingsIntent != null,
|
||||
onNavigationClick = viewModel::goToPreviousStage,
|
||||
mode = remember {
|
||||
MessageBackupsKeyRecordMode.Passkey(
|
||||
onSaveToPasswordManager = viewModel::onBackupKeySaveRequested,
|
||||
onSaveManually = viewModel::goToRecordManually,
|
||||
onSaveSuccessful = viewModel::goToNextStage
|
||||
)
|
||||
},
|
||||
onCopyToClipboardClick = { Util.copyToClipboard(context, it, CLIPBOARD_TIMEOUT_SECONDS) },
|
||||
onRequestSaveToPasswordManager = viewModel::onBackupKeySaveRequested,
|
||||
onConfirmSaveToPasswordManager = viewModel::onBackupKeySaveConfirmed,
|
||||
onSaveStateCleared = viewModel::onBackupKeySaveStateCleared,
|
||||
onSaveToPasswordManagerComplete = viewModel::onBackupKeySaveCompleted,
|
||||
onGoToPasswordManagerSettingsClick = { requireContext().startActivity(passwordManagerSettingsIntent) },
|
||||
notifyKeyIsSameAsOnDeviceBackupKey = SignalStore.backup.newLocalBackupsEnabled
|
||||
)
|
||||
}
|
||||
|
||||
composable(route = MessageBackupsStage.Route.BACKUP_KEY_RECORD_MANUALLY.name) {
|
||||
val context = LocalContext.current
|
||||
val passwordManagerSettingsIntent = AndroidCredentialRepository.getCredentialManagerSettingsIntent(requireContext())
|
||||
|
||||
MessageBackupsKeyRecordScreen(
|
||||
backupKey = state.accountEntropyPool.displayValue,
|
||||
keySaveState = state.backupKeySaveState,
|
||||
|
||||
+12
-3
@@ -53,6 +53,7 @@ import kotlin.time.Duration.Companion.seconds
|
||||
class MessageBackupsFlowViewModel(
|
||||
private val initialTierSelection: MessageBackupTier?,
|
||||
googlePlayApiAvailability: Int,
|
||||
private val isCredentialManagerSupported: Boolean,
|
||||
startScreen: MessageBackupsStage = if (SignalStore.backup.backupTier == null) MessageBackupsStage.EDUCATION else MessageBackupsStage.TYPE_SELECTION
|
||||
) : ViewModel(), BackupKeyCredentialManagerHandler {
|
||||
|
||||
@@ -238,8 +239,9 @@ class MessageBackupsFlowViewModel(
|
||||
when (it.stage) {
|
||||
MessageBackupsStage.CANCEL -> error("Unsupported state transition from terminal state CANCEL")
|
||||
MessageBackupsStage.EDUCATION -> it.copy(stage = MessageBackupsStage.BACKUP_KEY_EDUCATION)
|
||||
MessageBackupsStage.BACKUP_KEY_EDUCATION -> it.copy(stage = MessageBackupsStage.BACKUP_KEY_RECORD)
|
||||
MessageBackupsStage.BACKUP_KEY_RECORD -> it.copy(stage = MessageBackupsStage.BACKUP_KEY_VERIFY)
|
||||
MessageBackupsStage.BACKUP_KEY_EDUCATION -> it.copy(stage = if (isCredentialManagerSupported) MessageBackupsStage.BACKUP_KEY_RECORD else MessageBackupsStage.BACKUP_KEY_RECORD_MANUALLY)
|
||||
MessageBackupsStage.BACKUP_KEY_RECORD -> it.copy(stage = MessageBackupsStage.TYPE_SELECTION)
|
||||
MessageBackupsStage.BACKUP_KEY_RECORD_MANUALLY -> it.copy(stage = MessageBackupsStage.BACKUP_KEY_VERIFY)
|
||||
MessageBackupsStage.BACKUP_KEY_VERIFY -> it.copy(stage = MessageBackupsStage.TYPE_SELECTION)
|
||||
MessageBackupsStage.TYPE_SELECTION -> validateTypeAndUpdateState(it)
|
||||
MessageBackupsStage.CHECKOUT_SHEET -> it.copy(stage = MessageBackupsStage.PROCESS_PAYMENT)
|
||||
@@ -262,7 +264,8 @@ class MessageBackupsFlowViewModel(
|
||||
MessageBackupsStage.EDUCATION -> MessageBackupsStage.CANCEL
|
||||
MessageBackupsStage.BACKUP_KEY_EDUCATION -> MessageBackupsStage.EDUCATION
|
||||
MessageBackupsStage.BACKUP_KEY_RECORD -> MessageBackupsStage.BACKUP_KEY_EDUCATION
|
||||
MessageBackupsStage.BACKUP_KEY_VERIFY -> MessageBackupsStage.BACKUP_KEY_RECORD
|
||||
MessageBackupsStage.BACKUP_KEY_RECORD_MANUALLY -> if (isCredentialManagerSupported) MessageBackupsStage.BACKUP_KEY_RECORD else MessageBackupsStage.BACKUP_KEY_EDUCATION
|
||||
MessageBackupsStage.BACKUP_KEY_VERIFY -> MessageBackupsStage.BACKUP_KEY_RECORD_MANUALLY
|
||||
MessageBackupsStage.TYPE_SELECTION -> MessageBackupsStage.BACKUP_KEY_RECORD
|
||||
MessageBackupsStage.CHECKOUT_SHEET -> MessageBackupsStage.TYPE_SELECTION
|
||||
MessageBackupsStage.CREATING_IN_APP_PAYMENT -> MessageBackupsStage.CREATING_IN_APP_PAYMENT
|
||||
@@ -277,6 +280,12 @@ class MessageBackupsFlowViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun goToRecordManually() {
|
||||
internalStateFlow.update {
|
||||
it.copy(stage = MessageBackupsStage.BACKUP_KEY_RECORD_MANUALLY)
|
||||
}
|
||||
}
|
||||
|
||||
fun onMessageBackupTierUpdated(messageBackupTier: MessageBackupTier) {
|
||||
internalStateFlow.update {
|
||||
it.copy(
|
||||
|
||||
+243
-37
@@ -6,14 +6,22 @@
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.subscription
|
||||
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
|
||||
import androidx.annotation.UiContext
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.SizeTransform
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -22,6 +30,7 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
@@ -34,9 +43,16 @@ import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.CompositingStrategy
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
@@ -48,6 +64,7 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.compose.BottomSheets
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
@@ -85,6 +102,11 @@ sealed interface MessageBackupsKeyRecordMode {
|
||||
val isOptimizedStorageEnabled: Boolean,
|
||||
val canRotateKey: Boolean
|
||||
) : MessageBackupsKeyRecordMode
|
||||
data class Passkey(
|
||||
val onSaveToPasswordManager: () -> Unit,
|
||||
val onSaveManually: () -> Unit,
|
||||
val onSaveSuccessful: () -> Unit
|
||||
) : MessageBackupsKeyRecordMode
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,18 +158,24 @@ fun MessageBackupsKeyRecordScreen(
|
||||
onRequestSaveToPasswordManager: () -> Unit = {},
|
||||
onConfirmSaveToPasswordManager: () -> Unit = {},
|
||||
onSaveToPasswordManagerComplete: (CredentialManagerResult) -> Unit = {},
|
||||
onSaveStateCleared: () -> Unit = {},
|
||||
onGoToPasswordManagerSettingsClick: () -> Unit = {},
|
||||
mode: MessageBackupsKeyRecordMode = MessageBackupsKeyRecordMode.Next(onNextClick = {}),
|
||||
notifyKeyIsSameAsOnDeviceBackupKey: Boolean = false
|
||||
) {
|
||||
TemporaryScreenshotSecurity.bind()
|
||||
|
||||
val context = LocalContext.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val backupKeyString = remember(backupKey) {
|
||||
backupKey.chunked(4).joinToString(" ")
|
||||
}
|
||||
|
||||
if (mode is MessageBackupsKeyRecordMode.Next) {
|
||||
val showAsPasskey = mode is MessageBackupsKeyRecordMode.Passkey
|
||||
var showExpandedPasskey by remember { mutableStateOf(false) }
|
||||
|
||||
if (mode is MessageBackupsKeyRecordMode.Next || mode is MessageBackupsKeyRecordMode.Passkey) {
|
||||
RecordScreenBackHandler()
|
||||
}
|
||||
|
||||
@@ -183,6 +211,41 @@ fun MessageBackupsKeyRecordScreen(
|
||||
}
|
||||
}
|
||||
|
||||
var displayKeyVerificationError by remember { mutableStateOf(false) }
|
||||
if (displayKeyVerificationError) {
|
||||
ConfirmationFailureDialog(mode) {
|
||||
displayKeyVerificationError = false
|
||||
}
|
||||
}
|
||||
|
||||
var displayConfirmKey by remember { mutableStateOf(false) }
|
||||
if (displayConfirmKey) {
|
||||
val context = LocalContext.current
|
||||
val credentialId = stringResource(R.string.MessageBackupsKeyRecordScreen__backup_key_password_manager_id)
|
||||
val successMessage = stringResource(R.string.MessageBackupsKeyRecordScreen__recover_key_confirmed)
|
||||
ModalBottomSheet(
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||
containerColor = SignalTheme.colors.colorSurface1,
|
||||
onDismissRequest = { displayConfirmKey = false }
|
||||
) {
|
||||
ConfirmRecoveryKeySheet(
|
||||
onConfirm = {
|
||||
coroutineScope.launch {
|
||||
val retrieved = getKeyFromCredentialManager(context, credentialId)
|
||||
if (retrieved == backupKey) {
|
||||
Toast.makeText(context, successMessage, Toast.LENGTH_SHORT).show()
|
||||
(mode as? MessageBackupsKeyRecordMode.Passkey)?.onSaveSuccessful()
|
||||
} else {
|
||||
displayKeyVerificationError = true
|
||||
}
|
||||
}
|
||||
displayConfirmKey = false
|
||||
},
|
||||
onSeeAgain = { displayConfirmKey = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Scaffolds.Settings(
|
||||
title = "",
|
||||
navigationIcon = SignalIcons.ArrowStart.imageVector,
|
||||
@@ -216,7 +279,11 @@ fun MessageBackupsKeyRecordScreen(
|
||||
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.MessageBackupsKeyRecordScreen__record_your_backup_key),
|
||||
text = if (showAsPasskey) {
|
||||
stringResource(R.string.MessageBackupsKeyRecordScreen__save_your_recovery_key)
|
||||
} else {
|
||||
stringResource(R.string.MessageBackupsKeyRecordScreen__record_your_backup_key)
|
||||
},
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
@@ -225,6 +292,8 @@ fun MessageBackupsKeyRecordScreen(
|
||||
item {
|
||||
val text = if (notifyKeyIsSameAsOnDeviceBackupKey) {
|
||||
stringResource(R.string.MessageBackupsKeyRecordScreen__this_key_is_the_same_as_your_on_device_recovery_key)
|
||||
} else if (showAsPasskey) {
|
||||
stringResource(R.string.MessageBackupsKeyRecordScreen__your_recovery_key)
|
||||
} else {
|
||||
stringResource(R.string.MessageBackupsKeyRecordScreen__this_key_is_required_to_recover)
|
||||
}
|
||||
@@ -239,47 +308,113 @@ fun MessageBackupsKeyRecordScreen(
|
||||
}
|
||||
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(top = 24.dp, bottom = 16.dp)
|
||||
.background(
|
||||
color = SignalTheme.colors.colorSurface1,
|
||||
shape = RoundedCornerShape(10.dp)
|
||||
)
|
||||
.padding(24.dp)
|
||||
) {
|
||||
Text(
|
||||
text = backupKeyString,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
.copy(
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight(400),
|
||||
letterSpacing = 1.44.sp,
|
||||
lineHeight = 36.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
fontFamily = MonoTypeface.fontFamily()
|
||||
AnimatedContent(
|
||||
targetState = showAsPasskey && !showExpandedPasskey,
|
||||
transitionSpec = { fadeIn() togetherWith fadeOut() using SizeTransform(clip = false) },
|
||||
label = "passkey",
|
||||
modifier = Modifier.padding(top = 24.dp, bottom = 16.dp)
|
||||
) { isCollapsed ->
|
||||
if (isCollapsed) {
|
||||
Box(
|
||||
contentAlignment = Alignment.CenterEnd,
|
||||
modifier = Modifier.background(
|
||||
color = SignalTheme.colors.colorSurface1,
|
||||
shape = RoundedCornerShape(50.dp)
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = backupKeyString,
|
||||
maxLines = 1,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
.copy(
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight(400),
|
||||
letterSpacing = 1.44.sp,
|
||||
lineHeight = 36.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
fontFamily = MonoTypeface.fontFamily()
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(10.dp)
|
||||
.graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen }
|
||||
.drawWithContent {
|
||||
drawContent()
|
||||
drawRect(
|
||||
brush = Brush.horizontalGradient(
|
||||
0f to Color.Black,
|
||||
0.75f to Color.Transparent
|
||||
),
|
||||
blendMode = BlendMode.DstIn
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Buttons.Small(
|
||||
onClick = {
|
||||
if (mode is MessageBackupsKeyRecordMode.CreateNewKey) {
|
||||
displayRecoveryKeyCopyWarning = true
|
||||
} else {
|
||||
onCopyToClipboardClick(backupKeyString)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.background(
|
||||
color = SignalTheme.colors.colorSurface1,
|
||||
shape = RoundedCornerShape(50.dp)
|
||||
)
|
||||
.padding(horizontal = 12.dp)
|
||||
.clickable(onClick = { showExpandedPasskey = true }),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.symbol_tap_20),
|
||||
contentDescription = stringResource(R.string.MessageBackupsKeyRecordScreen__see_full_key)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.MessageBackupsKeyRecordScreen__see_full_key),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(start = 4.dp, end = 12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(
|
||||
color = SignalTheme.colors.colorSurface1,
|
||||
shape = RoundedCornerShape(10.dp)
|
||||
)
|
||||
.padding(24.dp)
|
||||
) {
|
||||
Text(
|
||||
text = backupKeyString,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
.copy(
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight(400),
|
||||
letterSpacing = 1.44.sp,
|
||||
lineHeight = 36.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
fontFamily = MonoTypeface.fontFamily()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.MessageBackupsKeyRecordScreen__copy_to_clipboard)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (AndroidCredentialRepository.isCredentialManagerSupported) {
|
||||
if (!showAsPasskey || showExpandedPasskey) {
|
||||
item {
|
||||
Buttons.Small(
|
||||
onClick = {
|
||||
if (mode is MessageBackupsKeyRecordMode.CreateNewKey) {
|
||||
displayRecoveryKeyCopyWarning = true
|
||||
} else {
|
||||
onCopyToClipboardClick(backupKeyString)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.MessageBackupsKeyRecordScreen__copy_to_clipboard)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!showAsPasskey && AndroidCredentialRepository.isCredentialManagerSupported(context)) {
|
||||
item {
|
||||
Buttons.Small(
|
||||
onClick = { onRequestSaveToPasswordManager() }
|
||||
@@ -300,6 +435,10 @@ fun MessageBackupsKeyRecordScreen(
|
||||
is MessageBackupsKeyRecordMode.CreateNewKey -> {
|
||||
CreateNewKeyButton(mode)
|
||||
}
|
||||
|
||||
is MessageBackupsKeyRecordMode.Passkey -> {
|
||||
SaveButtons(mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,7 +465,12 @@ fun MessageBackupsKeyRecordScreen(
|
||||
is BackupKeySaveState.Success -> {
|
||||
val snackbarMessage = stringResource(R.string.MessageBackupsKeyRecordScreen__save_to_password_manager_success)
|
||||
LaunchedEffect(keySaveState) {
|
||||
snackbarHostState.showSnackbar(snackbarMessage)
|
||||
if (showAsPasskey) {
|
||||
displayConfirmKey = true
|
||||
} else {
|
||||
snackbarHostState.showSnackbar(snackbarMessage)
|
||||
}
|
||||
onSaveStateCleared()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,6 +487,24 @@ fun MessageBackupsKeyRecordScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SaveButtons(mode: MessageBackupsKeyRecordMode.Passkey) {
|
||||
Buttons.LargeTonal(
|
||||
onClick = mode.onSaveToPasswordManager
|
||||
) {
|
||||
Text(text = stringResource(R.string.MessageBackupsKeyRecordScreen__save_to_password_manager))
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = mode.onSaveManually,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 24.dp)
|
||||
.horizontalGutters()
|
||||
) {
|
||||
Text(text = stringResource(R.string.MessageBackupsKeyRecordScreen__save_key_manually))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NextButton(onNextClick: () -> Unit) {
|
||||
Box(
|
||||
@@ -578,6 +740,26 @@ private fun KeyLimitExceededDialog(
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConfirmationFailureDialog(mode: MessageBackupsKeyRecordMode, onDismiss: () -> Unit) {
|
||||
Dialogs.AdvancedAlertDialog(
|
||||
title = stringResource(R.string.MessageBackupsKeyRecordScreen__recover_key_error),
|
||||
body = stringResource(R.string.MessageBackupsKeyRecordScreen__recover_key_error_body),
|
||||
positive = stringResource(R.string.MessageBackupsKeyRecordScreen__save_to_password_manager),
|
||||
onPositive = {
|
||||
(mode as? MessageBackupsKeyRecordMode.Passkey)?.onSaveToPasswordManager()
|
||||
onDismiss()
|
||||
},
|
||||
neutral = stringResource(R.string.MessageBackupsKeyRecordScreen__save_key_manually),
|
||||
onNeutral = {
|
||||
(mode as? MessageBackupsKeyRecordMode.Passkey)?.onSaveManually()
|
||||
onDismiss()
|
||||
},
|
||||
negative = stringResource(android.R.string.cancel),
|
||||
onNegative = onDismiss
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun saveKeyToCredentialManager(
|
||||
@UiContext activityContext: Context,
|
||||
backupKey: String
|
||||
@@ -589,6 +771,13 @@ private suspend fun saveKeyToCredentialManager(
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun getKeyFromCredentialManager(
|
||||
@UiContext activityContext: Context,
|
||||
id: String
|
||||
): String? {
|
||||
return AndroidCredentialRepository.getCredential(activityContext, id)
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun MessageBackupsKeyRecordScreenPreview() {
|
||||
@@ -621,6 +810,23 @@ private fun MessageBackupsKeyRecordScreenSameAsOnDeviceKeyPreview() {
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun MessageBackupsKeySaveScreenPreview() {
|
||||
Previews.Preview {
|
||||
MessageBackupsKeyRecordScreen(
|
||||
backupKey = (0 until 63).map { (('A'..'Z') + ('0'..'9')).random() }.joinToString("") + "0",
|
||||
keySaveState = null,
|
||||
canOpenPasswordManagerSettings = true,
|
||||
mode = MessageBackupsKeyRecordMode.Passkey(
|
||||
onSaveToPasswordManager = {},
|
||||
onSaveManually = {},
|
||||
onSaveSuccessful = {}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun SaveKeyConfirmationDialogPreview() {
|
||||
|
||||
+2
@@ -15,6 +15,7 @@ enum class MessageBackupsStage(
|
||||
EDUCATION(route = Route.EDUCATION),
|
||||
BACKUP_KEY_EDUCATION(route = Route.BACKUP_KEY_EDUCATION),
|
||||
BACKUP_KEY_RECORD(route = Route.BACKUP_KEY_RECORD),
|
||||
BACKUP_KEY_RECORD_MANUALLY(route = Route.BACKUP_KEY_RECORD_MANUALLY),
|
||||
BACKUP_KEY_VERIFY(route = Route.BACKUP_KEY_VERIFY),
|
||||
TYPE_SELECTION(route = Route.TYPE_SELECTION),
|
||||
CREATING_IN_APP_PAYMENT(route = Route.TYPE_SELECTION),
|
||||
@@ -32,6 +33,7 @@ enum class MessageBackupsStage(
|
||||
EDUCATION,
|
||||
BACKUP_KEY_EDUCATION,
|
||||
BACKUP_KEY_RECORD,
|
||||
BACKUP_KEY_RECORD_MANUALLY,
|
||||
BACKUP_KEY_VERIFY,
|
||||
TYPE_SELECTION;
|
||||
|
||||
|
||||
+2
@@ -34,6 +34,7 @@ import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentCheckoutDelegate
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.util.storage.AndroidCredentialRepository
|
||||
import org.thoughtcrime.securesms.util.viewModel
|
||||
|
||||
/**
|
||||
@@ -61,6 +62,7 @@ abstract class UpgradeToPaidTierBottomSheet : ComposeBottomSheetDialogFragment()
|
||||
MessageBackupsFlowViewModel(
|
||||
initialTierSelection = MessageBackupTier.PAID,
|
||||
googlePlayApiAvailability = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(requireContext()),
|
||||
isCredentialManagerSupported = AndroidCredentialRepository.isCredentialManagerSupported(requireContext()),
|
||||
startScreen = MessageBackupsStage.TYPE_SELECTION
|
||||
)
|
||||
}
|
||||
|
||||
+3
@@ -27,6 +27,9 @@ interface BackupKeyCredentialManagerHandler {
|
||||
/** Called when the user confirms they want to save the backup key to the password manager. */
|
||||
fun onBackupKeySaveConfirmed() = updateBackupKeySaveState(BackupKeySaveState.AwaitingCredentialManager(isRetry = false))
|
||||
|
||||
/** Called to clear the key save state. */
|
||||
fun onBackupKeySaveStateCleared() = updateBackupKeySaveState(null)
|
||||
|
||||
/** Handles the password manager save operation response. */
|
||||
fun onBackupKeySaveCompleted(result: CredentialManagerResult) {
|
||||
when (result) {
|
||||
|
||||
+32
-1
@@ -14,11 +14,15 @@ import android.view.autofill.AutofillManager
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.credentials.CreatePasswordRequest
|
||||
import androidx.credentials.CredentialManager
|
||||
import androidx.credentials.GetCredentialRequest
|
||||
import androidx.credentials.GetPasswordOption
|
||||
import androidx.credentials.PasswordCredential
|
||||
import androidx.credentials.exceptions.CreateCredentialCancellationException
|
||||
import androidx.credentials.exceptions.CreateCredentialInterruptedException
|
||||
import androidx.credentials.exceptions.CreateCredentialNoCreateOptionException
|
||||
import androidx.credentials.exceptions.CreateCredentialProviderConfigurationException
|
||||
import androidx.credentials.exceptions.CreateCredentialUnknownException
|
||||
import androidx.credentials.exceptions.GetCredentialException
|
||||
import org.signal.core.util.logging.Log
|
||||
|
||||
/**
|
||||
@@ -31,7 +35,13 @@ object AndroidCredentialRepository {
|
||||
private const val ERROR_CODE_MISSING_CREDENTIAL_MANAGER = "[28434]"
|
||||
private const val ERROR_CODE_SAVE_PROMPT_DISABLED = "[28435]"
|
||||
|
||||
val isCredentialManagerSupported: Boolean = Build.VERSION.SDK_INT >= 19
|
||||
fun isCredentialManagerSupported(context: Context): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= 26) {
|
||||
context.getSystemService<AutofillManager>()?.isEnabled == true
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveCredential(
|
||||
activityContext: Context,
|
||||
@@ -81,6 +91,27 @@ object AndroidCredentialRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a previously saved password credential by username. Returns null if the credential
|
||||
* cannot be found or if retrieval fails.
|
||||
*/
|
||||
suspend fun getCredential(
|
||||
activityContext: Context,
|
||||
id: String
|
||||
): String? = try {
|
||||
val result = CredentialManager.create(activityContext).getCredential(activityContext, GetCredentialRequest(listOf(GetPasswordOption())))
|
||||
val credential = result.credential
|
||||
if (credential is PasswordCredential && credential.id == id) {
|
||||
credential.password
|
||||
} else {
|
||||
Log.w(TAG, "Failed to find credential from password manager")
|
||||
null
|
||||
}
|
||||
} catch (e: GetCredentialException) {
|
||||
Log.w(TAG, "Failed to find credential from password manager.", e)
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an [Intent] that can be used to launch the device's password manager settings.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="80dp"
|
||||
android:height="80dp"
|
||||
android:viewportWidth="80"
|
||||
android:viewportHeight="80">
|
||||
<path
|
||||
android:pathData="M40,40m-40,0a40,40 0,1 1,80 0a40,40 0,1 1,-80 0"
|
||||
android:fillColor="#E0E5FF"/>
|
||||
<path
|
||||
android:pathData="M39.99,24.073C39.001,24.073 38.168,23.256 38.168,22.287C38.168,21.298 39.001,20.5 39.99,20.5C40.999,20.5 41.813,21.298 41.813,22.287C41.813,23.256 40.999,24.073 39.99,24.073ZM45.574,24.966C44.586,24.966 43.752,24.149 43.752,23.18C43.752,22.191 44.586,21.393 45.574,21.393C46.583,21.393 47.397,22.191 47.397,23.18C47.397,24.149 46.583,24.966 45.574,24.966ZM34.406,24.966C33.417,24.966 32.584,24.149 32.584,23.18C32.584,22.191 33.417,21.393 34.406,21.393C35.414,21.393 36.229,22.191 36.229,23.18C36.229,24.149 35.414,24.966 34.406,24.966ZM50.654,27.475C49.646,27.475 48.832,26.658 48.832,25.689C48.832,24.7 49.646,23.902 50.654,23.902C51.643,23.902 52.477,24.7 52.477,25.689C52.477,26.658 51.643,27.475 50.654,27.475ZM29.346,27.475C28.337,27.475 27.523,26.658 27.523,25.689C27.523,24.7 28.337,23.902 29.346,23.902C30.354,23.902 31.168,24.7 31.168,25.689C31.168,26.658 30.354,27.475 29.346,27.475ZM54.649,31.39C53.66,31.39 52.826,30.592 52.826,29.604C52.826,28.635 53.66,27.817 54.649,27.817C55.657,27.817 56.471,28.635 56.471,29.604C56.471,30.592 55.657,31.39 54.649,31.39ZM25.332,31.39C24.343,31.39 23.51,30.592 23.51,29.604C23.51,28.635 24.343,27.817 25.332,27.817C26.34,27.817 27.155,28.635 27.155,29.604C27.155,30.592 26.34,31.39 25.332,31.39ZM37.916,49.028C37.198,49.028 36.636,48.743 36.112,48.077L31.537,42.604C31.207,42.205 31.032,41.787 31.032,41.33C31.032,40.38 31.789,39.639 32.739,39.639C33.301,39.639 33.766,39.848 34.232,40.437L37.838,44.942L45.497,32.892C45.904,32.246 46.428,31.941 47.009,31.941C47.921,31.941 48.774,32.569 48.774,33.5C48.774,33.937 48.541,34.393 48.289,34.792L39.622,48.077C39.215,48.686 38.614,49.028 37.916,49.028ZM57.208,36.294C56.2,36.294 55.385,35.496 55.385,34.507C55.385,33.538 56.2,32.721 57.208,32.721C58.216,32.721 59.03,33.538 59.03,34.507C59.03,35.496 58.216,36.294 57.208,36.294ZM22.792,36.294C21.784,36.294 20.969,35.496 20.969,34.507C20.969,33.538 21.784,32.721 22.792,32.721C23.781,32.721 24.615,33.538 24.615,34.507C24.615,35.496 23.781,36.294 22.792,36.294ZM58.158,41.786C57.169,41.786 56.335,40.988 56.335,40C56.335,39.012 57.169,38.213 58.158,38.213C59.166,38.213 60,39.012 60,40C60,40.988 59.166,41.786 58.158,41.786ZM21.823,41.786C20.814,41.786 20,40.988 20,40C20,39.012 20.814,38.213 21.823,38.213C22.831,38.213 23.645,39.012 23.645,40C23.645,40.988 22.831,41.786 21.823,41.786ZM57.208,47.279C56.2,47.279 55.385,46.462 55.385,45.493C55.385,44.504 56.2,43.706 57.208,43.706C58.216,43.706 59.03,44.504 59.03,45.493C59.03,46.462 58.216,47.279 57.208,47.279ZM22.792,47.279C21.784,47.279 20.969,46.462 20.969,45.493C20.969,44.504 21.784,43.706 22.792,43.706C23.781,43.706 24.615,44.504 24.615,45.493C24.615,46.462 23.781,47.279 22.792,47.279ZM54.649,52.183C53.66,52.183 52.826,51.366 52.826,50.396C52.826,49.408 53.66,48.61 54.649,48.61C55.657,48.61 56.471,49.408 56.471,50.396C56.471,51.366 55.657,52.183 54.649,52.183ZM25.332,52.183C24.343,52.183 23.51,51.366 23.51,50.396C23.51,49.408 24.343,48.61 25.332,48.61C26.34,48.61 27.155,49.408 27.155,50.396C27.155,51.366 26.34,52.183 25.332,52.183ZM50.654,56.117C49.646,56.117 48.832,55.3 48.832,54.33C48.832,53.342 49.646,52.525 50.654,52.525C51.643,52.525 52.477,53.342 52.477,54.33C52.477,55.3 51.643,56.117 50.654,56.117ZM29.346,56.117C28.337,56.117 27.523,55.3 27.523,54.33C27.523,53.342 28.337,52.525 29.346,52.525C30.354,52.525 31.168,53.342 31.168,54.33C31.168,55.3 30.354,56.117 29.346,56.117ZM45.574,58.607C44.586,58.607 43.752,57.808 43.752,56.82C43.752,55.851 44.586,55.034 45.574,55.034C46.583,55.034 47.397,55.851 47.397,56.82C47.397,57.808 46.583,58.607 45.574,58.607ZM34.406,58.607C33.417,58.607 32.584,57.808 32.584,56.82C32.584,55.851 33.417,55.034 34.406,55.034C35.414,55.034 36.229,55.851 36.229,56.82C36.229,57.808 35.414,58.607 34.406,58.607ZM39.99,59.5C39.001,59.5 38.168,58.702 38.168,57.714C38.168,56.744 39.001,55.927 39.99,55.927C40.999,55.927 41.813,56.744 41.813,57.714C41.813,58.702 40.999,59.5 39.99,59.5Z"
|
||||
android:fillColor="#3B45FD"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,13 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="20dp"
|
||||
android:height="20dp"
|
||||
android:viewportWidth="20"
|
||||
android:viewportHeight="20">
|
||||
<path
|
||||
android:pathData="M2.813,5.416C2.813,3.518 4.352,1.979 6.25,1.979C7.912,1.979 9.3,3.159 9.619,4.728C9.699,5.123 10.084,5.378 10.478,5.298C10.873,5.217 11.128,4.833 11.048,4.438C10.594,2.203 8.619,0.521 6.25,0.521C3.546,0.521 1.354,2.712 1.354,5.416C1.354,6.592 1.769,7.672 2.46,8.516C2.715,8.828 3.175,8.873 3.486,8.618C3.798,8.363 3.844,7.904 3.589,7.592C3.103,6.999 2.813,6.243 2.813,5.416Z"
|
||||
android:fillColor="#000000"/>
|
||||
<path
|
||||
android:pathData="M5.597,3.557C4.57,3.931 4.041,5.067 4.415,6.094L5.789,9.88C6.079,10.666 6.285,11.099 6.694,11.861C6.442,11.551 6.258,11.35 6.001,11.178C5.124,10.64 4.035,10.933 3.393,11.514C3.228,11.663 3.051,11.852 2.917,12.035C2.449,12.677 2.564,13.496 3.175,14.03L6.944,17.5C8.887,19.284 11.871,20.119 14.395,19.2C16.25,18.525 17.582,17.33 18.256,15.775C18.927,14.226 18.907,12.408 18.23,10.549L17.605,8.831C17.259,7.882 16.62,7.218 15.788,7.004C15.236,6.863 14.676,6.94 14.167,7.178C13.801,6.715 13.323,6.4 12.776,6.29C12.158,6.166 11.548,6.323 11.042,6.697C10.756,6.512 10.429,6.397 10.084,6.365C9.65,6.326 9.217,6.42 8.826,6.64L8.134,4.74C7.76,3.713 6.625,3.183 5.597,3.557ZM15.113,9.295L14.818,8.483C15.047,8.384 15.257,8.374 15.425,8.417C15.681,8.483 16.01,8.715 16.234,9.33L16.859,11.048C17.436,12.631 17.412,14.055 16.917,15.195C16.426,16.329 15.432,17.271 13.896,17.83C11.973,18.53 9.653,17.933 7.925,16.421C6.649,15.246 5.361,14.082 4.095,12.895C4.184,12.793 4.271,12.686 4.372,12.595C4.84,12.171 5.386,12.376 5.789,12.739L7.477,14.259C7.726,14.483 8.095,14.509 8.373,14.322C8.65,14.134 8.765,13.782 8.65,13.468L5.785,5.595C5.687,5.325 5.826,5.026 6.096,4.928C6.367,4.829 6.665,4.969 6.764,5.239L7.83,8.169C7.832,8.174 7.834,8.178 7.835,8.183L9.044,11.503C9.182,11.882 9.6,12.077 9.979,11.939C10.357,11.802 10.552,11.383 10.414,11.005L9.342,8.059C9.559,7.852 9.783,7.802 9.952,7.818C10.191,7.839 10.429,7.998 10.538,8.299L11.393,10.649C11.531,11.027 11.949,11.222 12.328,11.084C12.706,10.946 12.901,10.528 12.764,10.15L11.928,7.854C12.13,7.713 12.329,7.688 12.488,7.72C12.736,7.77 13.09,8 13.316,8.621L13.742,9.793C13.88,10.172 14.299,10.367 14.677,10.229C15.055,10.092 15.25,9.673 15.113,9.295Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillType="evenOdd"/>
|
||||
</vector>
|
||||
@@ -9114,6 +9114,24 @@
|
||||
<string name="MessageBackupsKeyRecordScreen__you_have_not_finished_setting_up_backups">You have not finished setting up backups, and no backup has been created. You can enable backups in Settings.</string>
|
||||
<!-- Dialog confirmation button to exit backup setup -->
|
||||
<string name="MessageBackupsKeyRecordScreen__exit_backup_setup_confirm">Exit backup setup</string>
|
||||
<!-- Screen title when saving your recovery key -->
|
||||
<string name="MessageBackupsKeyRecordScreen__save_your_recovery_key">Save your recovery key</string>
|
||||
<!-- Screen subtitle for the recovery key screen -->
|
||||
<string name="MessageBackupsKeyRecordScreen__your_recovery_key">Your recovery key is a 64-character code that lets you restore your backup. If you forget your key, you will not be able to recover your account.</string>
|
||||
<!-- Hint text to see the full recovery key -->
|
||||
<string name="MessageBackupsKeyRecordScreen__see_full_key">See full key</string>
|
||||
<!-- Bottom sheet caption to confirm your recovery key -->
|
||||
<string name="MessageBackupsKeyRecordScreen__confirm_that_your_recovery">Confirm that your recovery key was recorded correctly. You will be prompted to fill your saved password in the next step.</string>
|
||||
<!-- Button to confirm the recovery key -->
|
||||
<string name="MessageBackupsKeyRecordScreen__confirm_recovery">Confirm recovery key</string>
|
||||
<!-- Toast shown when the key is confirmed -->
|
||||
<string name="MessageBackupsKeyRecordScreen__recover_key_confirmed">Recovery key confirmed</string>
|
||||
<!-- Dialog title shown when the recovery key fails to confirm -->
|
||||
<string name="MessageBackupsKeyRecordScreen__recover_key_error">Error confirming recovery key</string>
|
||||
<!-- Dialog body shown when the recovery key fails to confirm -->
|
||||
<string name="MessageBackupsKeyRecordScreen__recover_key_error_body">Your recovery key could not be confirmed. Make sure you’ve saved it in your password manager, or save it manually.</string>
|
||||
<!-- Button option to save the key manually -->
|
||||
<string name="MessageBackupsKeyRecordScreen__save_key_manually">Save key manually</string>
|
||||
|
||||
<!-- BackupKeyDisplayFragment -->
|
||||
<!-- Dialog title when exiting before confirming new key -->
|
||||
|
||||
Reference in New Issue
Block a user