mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-24 04:58:45 +00:00
Add verify key megaphone.
This commit is contained in:
committed by
Jeffrey Starke
parent
c6afa17330
commit
359f473b59
@@ -1137,6 +1137,11 @@
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity android:name=".backup.v2.ui.verify.VerifyBackupKeyActivity"
|
||||
android:exported="false"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:theme="@style/Signal.DayNight.NoActionBar" />
|
||||
|
||||
<activity android:name=".groups.ui.incommon.GroupsInCommonActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Signal.DayNight.NoActionBar" />
|
||||
|
||||
@@ -33,6 +33,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective
|
||||
@@ -67,6 +68,7 @@ import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.getSerializableCompat
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.donations.StripeApi
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.verify.VerifyBackupKeyActivity
|
||||
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar.show
|
||||
import org.thoughtcrime.securesms.calls.log.CallLogFilter
|
||||
import org.thoughtcrime.securesms.calls.log.CallLogFragment
|
||||
@@ -599,6 +601,16 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (resultCode == RESULT_OK && requestCode == VerifyBackupKeyActivity.REQUEST_CODE) {
|
||||
mainNavigationViewModel.setSnackbar(
|
||||
SnackbarState(
|
||||
message = getString(R.string.VerifyBackupKey__backup_key_correct),
|
||||
duration = SnackbarDuration.Short
|
||||
)
|
||||
)
|
||||
mainNavigationViewModel.onMegaphoneSnoozed(Megaphones.Event.VERIFY_BACKUP_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFirstRender() {
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.subscription
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
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.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.horizontalGutters
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.fonts.MonoTypeface
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.restore.BackupKeyVisualTransformation
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.restore.attachBackupKeyAutoFillHelper
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.restore.backupKeyAutoFillHelper
|
||||
import org.whispersystems.signalservice.api.AccountEntropyPool
|
||||
|
||||
/**
|
||||
* Screen to enter backup key with an option to view the backup key again
|
||||
*/
|
||||
@Composable
|
||||
fun EnterKeyScreen(
|
||||
paddingValues: PaddingValues,
|
||||
backupKey: String,
|
||||
onNextClick: () -> Unit,
|
||||
captionContent: @Composable () -> Unit,
|
||||
seeKeyButton: @Composable () -> Unit
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.consumeWindowInsets(paddingValues)
|
||||
.imePadding()
|
||||
.fillMaxSize()
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val visualTransform = remember { BackupKeyVisualTransformation(chunkSize = 4) }
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
var enteredBackupKey by remember { mutableStateOf("") }
|
||||
var isBackupKeyValid by remember { mutableStateOf(false) }
|
||||
var showError by remember { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(scrollState)
|
||||
.weight(weight = 1f, fill = false)
|
||||
.horizontalGutters(),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
captionContent()
|
||||
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
val updateEnteredBackupKey = { input: String ->
|
||||
enteredBackupKey = AccountEntropyPool.removeIllegalCharacters(input).uppercase()
|
||||
isBackupKeyValid = enteredBackupKey == backupKey
|
||||
showError = !isBackupKeyValid && enteredBackupKey.length >= backupKey.length
|
||||
}
|
||||
|
||||
var requestFocus: Boolean by remember { mutableStateOf(true) }
|
||||
val autoFillHelper = backupKeyAutoFillHelper { updateEnteredBackupKey(it) }
|
||||
|
||||
TextField(
|
||||
value = enteredBackupKey,
|
||||
onValueChange = {
|
||||
updateEnteredBackupKey(it)
|
||||
autoFillHelper.onValueChanged(it)
|
||||
},
|
||||
label = {
|
||||
Text(text = stringResource(id = R.string.MessageBackupsKeyVerifyScreen__backup_key))
|
||||
},
|
||||
textStyle = LocalTextStyle.current.copy(
|
||||
fontFamily = MonoTypeface.fontFamily(),
|
||||
lineHeight = 36.sp
|
||||
),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
imeAction = ImeAction.Next,
|
||||
autoCorrectEnabled = false
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = {
|
||||
if (isBackupKeyValid) {
|
||||
keyboardController?.hide()
|
||||
onNextClick()
|
||||
}
|
||||
}
|
||||
),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
),
|
||||
supportingText = { if (showError) Text(text = stringResource(R.string.MessageBackupsKeyVerifyScreen__incorrect_backup_key)) },
|
||||
isError = showError,
|
||||
minLines = 4,
|
||||
visualTransformation = visualTransform,
|
||||
modifier = Modifier
|
||||
.testTag("message-backups-key-verify-screen-backup-key-input-field")
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
.attachBackupKeyAutoFillHelper(autoFillHelper)
|
||||
.onGloballyPositioned {
|
||||
if (requestFocus) {
|
||||
focusRequester.requestFocus()
|
||||
requestFocus = false
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Surface(
|
||||
shadowElevation = if (scrollState.canScrollForward) 8.dp else 0.dp,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.padding(top = 8.dp, bottom = 24.dp)
|
||||
.horizontalGutters()
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
seeKeyButton()
|
||||
|
||||
Buttons.LargeTonal(
|
||||
enabled = isBackupKeyValid,
|
||||
onClick = onNextClick
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.RegistrationActivity_next)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,71 +6,40 @@
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.subscription
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
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.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
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.Previews
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.signal.core.ui.compose.horizontalGutters
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.fonts.MonoTypeface
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.restore.BackupKeyVisualTransformation
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.restore.attachBackupKeyAutoFillHelper
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.restore.backupKeyAutoFillHelper
|
||||
import org.whispersystems.signalservice.api.AccountEntropyPool
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import kotlin.random.Random
|
||||
import kotlin.random.nextInt
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
@@ -78,7 +47,7 @@ import org.signal.core.ui.R as CoreUiR
|
||||
/**
|
||||
* Prompt user to re-enter backup key (AEP) to confirm they have it still.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MessageBackupsKeyVerifyScreen(
|
||||
backupKey: String,
|
||||
@@ -96,127 +65,28 @@ fun MessageBackupsKeyVerifyScreen(
|
||||
onNavigationClick = onNavigationClick
|
||||
) { paddingValues ->
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.imePadding()
|
||||
.fillMaxSize()
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val visualTransform = remember { BackupKeyVisualTransformation(chunkSize = 4) }
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
var enteredBackupKey by remember { mutableStateOf("") }
|
||||
var isBackupKeyValid by remember { mutableStateOf(false) }
|
||||
var showError by remember { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(scrollState)
|
||||
.weight(weight = 1f, fill = false)
|
||||
.horizontalGutters(),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
EnterKeyScreen(
|
||||
paddingValues = paddingValues,
|
||||
backupKey = backupKey,
|
||||
onNextClick = {
|
||||
coroutineScope.launch { sheetState.show() }
|
||||
},
|
||||
captionContent = {
|
||||
Text(
|
||||
text = stringResource(R.string.MessageBackupsKeyVerifyScreen__enter_the_backup_key_that_you_just_recorded),
|
||||
style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
val updateEnteredBackupKey = { input: String ->
|
||||
enteredBackupKey = AccountEntropyPool.removeIllegalCharacters(input).uppercase()
|
||||
isBackupKeyValid = enteredBackupKey == backupKey
|
||||
showError = !isBackupKeyValid && enteredBackupKey.length >= backupKey.length
|
||||
}
|
||||
|
||||
var requestFocus: Boolean by remember { mutableStateOf(true) }
|
||||
val autoFillHelper = backupKeyAutoFillHelper { updateEnteredBackupKey(it) }
|
||||
|
||||
TextField(
|
||||
value = enteredBackupKey,
|
||||
onValueChange = {
|
||||
updateEnteredBackupKey(it)
|
||||
autoFillHelper.onValueChanged(it)
|
||||
},
|
||||
label = {
|
||||
Text(text = stringResource(id = R.string.MessageBackupsKeyVerifyScreen__backup_key))
|
||||
},
|
||||
textStyle = LocalTextStyle.current.copy(
|
||||
fontFamily = MonoTypeface.fontFamily(),
|
||||
lineHeight = 36.sp
|
||||
),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
imeAction = ImeAction.Next,
|
||||
autoCorrectEnabled = false
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = {
|
||||
if (isBackupKeyValid) {
|
||||
keyboardController?.hide()
|
||||
coroutineScope.launch { sheetState.show() }
|
||||
}
|
||||
}
|
||||
),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
),
|
||||
supportingText = { if (showError) Text(text = stringResource(R.string.MessageBackupsKeyVerifyScreen__incorrect_backup_key)) },
|
||||
isError = showError,
|
||||
minLines = 4,
|
||||
visualTransformation = visualTransform,
|
||||
modifier = Modifier
|
||||
.testTag("message-backups-key-verify-screen-backup-key-input-field")
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
.attachBackupKeyAutoFillHelper(autoFillHelper)
|
||||
.onGloballyPositioned {
|
||||
if (requestFocus) {
|
||||
focusRequester.requestFocus()
|
||||
requestFocus = false
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Surface(
|
||||
shadowElevation = if (scrollState.canScrollForward) 8.dp else 0.dp,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.padding(top = 8.dp, bottom = 24.dp)
|
||||
.horizontalGutters()
|
||||
.fillMaxWidth()
|
||||
},
|
||||
seeKeyButton = {
|
||||
TextButton(
|
||||
onClick = onNavigationClick
|
||||
) {
|
||||
TextButton(
|
||||
onClick = onNavigationClick
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.MessageBackupsKeyVerifyScreen__see_key_again)
|
||||
)
|
||||
}
|
||||
|
||||
Buttons.LargeTonal(
|
||||
enabled = isBackupKeyValid,
|
||||
onClick = {
|
||||
coroutineScope.launch { sheetState.show() }
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.RegistrationActivity_next)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(id = R.string.MessageBackupsKeyVerifyScreen__see_key_again)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (sheetState.isVisible) {
|
||||
ModalBottomSheet(
|
||||
@@ -225,6 +95,7 @@ fun MessageBackupsKeyVerifyScreen(
|
||||
containerColor = SignalTheme.colors.colorSurface1,
|
||||
onDismissRequest = {
|
||||
coroutineScope.launch {
|
||||
SignalStore.backup.lastVerifyKeyTime = System.currentTimeMillis()
|
||||
sheetState.hide()
|
||||
}
|
||||
}
|
||||
@@ -232,6 +103,7 @@ fun MessageBackupsKeyVerifyScreen(
|
||||
BottomSheetContent(
|
||||
onContinueClick = {
|
||||
coroutineScope.launch {
|
||||
SignalStore.backup.lastVerifyKeyTime = System.currentTimeMillis()
|
||||
sheetState.hide()
|
||||
}
|
||||
onNextClick()
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.verify
|
||||
|
||||
import android.app.Activity.RESULT_OK
|
||||
import androidx.compose.runtime.Composable
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsKeyVerifyScreen
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
|
||||
/**
|
||||
* Fragment to confirm the backup key just shown after users forget it.
|
||||
*/
|
||||
class ConfirmBackupKeyDisplayFragment : ComposeFragment() {
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
MessageBackupsKeyVerifyScreen(
|
||||
backupKey = SignalStore.account.accountEntropyPool.displayValue,
|
||||
onNavigationClick = {
|
||||
requireActivity().supportFragmentManager.popBackStack()
|
||||
},
|
||||
onNextClick = {
|
||||
SignalStore.backup.lastVerifyKeyTime = System.currentTimeMillis()
|
||||
SignalStore.backup.hasVerifiedBefore = true
|
||||
SignalStore.backup.hasSnoozedVerified = false
|
||||
requireActivity().setResult(RESULT_OK)
|
||||
requireActivity().finish()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.verify
|
||||
|
||||
import android.R
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsKeyRecordScreen
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.thoughtcrime.securesms.util.storage.AndroidCredentialRepository
|
||||
import org.thoughtcrime.securesms.util.viewModel
|
||||
|
||||
/**
|
||||
* Fragment which displays the backup key to the user after users forget it.
|
||||
*/
|
||||
class ForgotBackupKeyFragment : ComposeFragment() {
|
||||
|
||||
companion object {
|
||||
const val CLIPBOARD_TIMEOUT_SECONDS = 60
|
||||
}
|
||||
|
||||
private val viewModel: ForgotBackupKeyViewModel by viewModel { ForgotBackupKeyViewModel() }
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val passwordManagerSettingsIntent = AndroidCredentialRepository.getCredentialManagerSettingsIntent(requireContext())
|
||||
|
||||
MessageBackupsKeyRecordScreen(
|
||||
backupKey = SignalStore.account.accountEntropyPool.displayValue,
|
||||
keySaveState = state.keySaveState,
|
||||
canOpenPasswordManagerSettings = passwordManagerSettingsIntent != null,
|
||||
onNavigationClick = { requireActivity().supportFragmentManager.popBackStack() },
|
||||
onCopyToClipboardClick = { Util.copyToClipboard(requireContext(), it, CLIPBOARD_TIMEOUT_SECONDS) },
|
||||
onRequestSaveToPasswordManager = viewModel::onBackupKeySaveRequested,
|
||||
onConfirmSaveToPasswordManager = viewModel::onBackupKeySaveConfirmed,
|
||||
onSaveToPasswordManagerComplete = viewModel::onBackupKeySaveCompleted,
|
||||
onNextClick = {
|
||||
requireActivity()
|
||||
.supportFragmentManager
|
||||
.beginTransaction()
|
||||
.add(R.id.content, ConfirmBackupKeyDisplayFragment())
|
||||
.addToBackStack(null)
|
||||
.commit()
|
||||
},
|
||||
onGoToPasswordManagerSettingsClick = { requireContext().startActivity(passwordManagerSettingsIntent) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.verify
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeyCredentialManagerHandler
|
||||
import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeySaveState
|
||||
|
||||
/**
|
||||
* View model for [ForgotBackupKeyFragment]
|
||||
*/
|
||||
class ForgotBackupKeyViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
|
||||
private val _uiState = MutableStateFlow(BackupKeyDisplayUiState())
|
||||
val uiState: StateFlow<BackupKeyDisplayUiState> = _uiState
|
||||
|
||||
override fun updateBackupKeySaveState(newState: BackupKeySaveState?) {
|
||||
_uiState.update { it.copy(keySaveState = newState) }
|
||||
}
|
||||
}
|
||||
|
||||
data class BackupKeyDisplayUiState(
|
||||
val keySaveState: BackupKeySaveState? = null
|
||||
)
|
||||
@@ -0,0 +1,191 @@
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.verify
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.withLink
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.BiometricDeviceAuthentication
|
||||
import org.thoughtcrime.securesms.BiometricDeviceLockContract
|
||||
import org.thoughtcrime.securesms.PassphraseRequiredActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.subscription.EnterKeyScreen
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import kotlin.random.Random
|
||||
import kotlin.random.nextInt
|
||||
|
||||
/**
|
||||
* Screen to verify the backup key
|
||||
*/
|
||||
class VerifyBackupKeyActivity : PassphraseRequiredActivity() {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(VerifyBackupKeyActivity::class)
|
||||
|
||||
@JvmStatic
|
||||
fun createIntent(context: Context): Intent {
|
||||
return Intent(context, VerifyBackupKeyActivity::class.java)
|
||||
}
|
||||
|
||||
const val REQUEST_CODE = 1204
|
||||
}
|
||||
|
||||
private lateinit var biometricDeviceAuthentication: BiometricDeviceAuthentication
|
||||
private lateinit var biometricDeviceLockLauncher: ActivityResultLauncher<String>
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
|
||||
enableEdgeToEdge()
|
||||
|
||||
setContent {
|
||||
SignalTheme {
|
||||
VerifyBackupPinScreen(
|
||||
backupKey = SignalStore.account.accountEntropyPool.displayValue,
|
||||
onForgotKeyClick = {
|
||||
if (!biometricDeviceAuthentication.authenticate(this, true) { biometricDeviceLockLauncher.launch(getString(R.string.RemoteBackupsSettingsFragment__unlock_to_view_backup_key)) }) {
|
||||
displayBackupKey()
|
||||
}
|
||||
},
|
||||
onNextClick = {
|
||||
SignalStore.backup.lastVerifyKeyTime = System.currentTimeMillis()
|
||||
SignalStore.backup.hasVerifiedBefore = true
|
||||
SignalStore.backup.hasSnoozedVerified = false
|
||||
setResult(RESULT_OK)
|
||||
finish()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
initializeBiometricAuth()
|
||||
}
|
||||
|
||||
private fun initializeBiometricAuth() {
|
||||
val biometricPrompt = BiometricPrompt(this, AuthListener())
|
||||
val promptInfo: BiometricPrompt.PromptInfo = BiometricPrompt.PromptInfo.Builder()
|
||||
.setAllowedAuthenticators(BiometricDeviceAuthentication.ALLOWED_AUTHENTICATORS)
|
||||
.setTitle(getString(R.string.RemoteBackupsSettingsFragment__unlock_to_view_backup_key))
|
||||
.build()
|
||||
|
||||
biometricDeviceAuthentication = BiometricDeviceAuthentication(BiometricManager.from(this), biometricPrompt, promptInfo)
|
||||
biometricDeviceLockLauncher = registerForActivityResult(BiometricDeviceLockContract()) { result: Int ->
|
||||
if (result == BiometricDeviceAuthentication.AUTHENTICATED) {
|
||||
displayBackupKey()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun displayBackupKey() {
|
||||
supportFragmentManager
|
||||
.beginTransaction()
|
||||
.add(android.R.id.content, ForgotBackupKeyFragment())
|
||||
.addToBackStack(null)
|
||||
.commit()
|
||||
}
|
||||
|
||||
private inner class AuthListener : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationFailed() {
|
||||
Log.w(TAG, "onAuthenticationFailed")
|
||||
Toast.makeText(this@VerifyBackupKeyActivity, R.string.RemoteBackupsSettingsFragment__authenticatino_required, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
Log.i(TAG, "onAuthenticationSucceeded")
|
||||
displayBackupKey()
|
||||
}
|
||||
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||
Log.w(TAG, "onAuthenticationError: $errorCode, $errString")
|
||||
onAuthenticationFailed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VerifyBackupPinScreen(
|
||||
backupKey: String,
|
||||
onForgotKeyClick: () -> Unit = {},
|
||||
onNextClick: () -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
val text = buildAnnotatedString {
|
||||
append(stringResource(id = R.string.VerifyBackupPinScreen__enter_the_backup_key_that_you_recorded))
|
||||
append(" ")
|
||||
|
||||
withLink(
|
||||
LinkAnnotation.Clickable(tag = "learn-more") {
|
||||
CommunicationActions.openBrowserLink(context, context.getString(R.string.backup_failed_support_url))
|
||||
}
|
||||
) {
|
||||
withStyle(SpanStyle(color = MaterialTheme.colorScheme.primary)) {
|
||||
append(stringResource(id = R.string.BackupAlertBottomSheet__learn_more))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold { paddingValues ->
|
||||
EnterKeyScreen(
|
||||
paddingValues = paddingValues,
|
||||
backupKey = backupKey,
|
||||
onNextClick = onNextClick,
|
||||
captionContent = {
|
||||
Text(
|
||||
text = stringResource(R.string.VerifyBackupPinScreen__enter_your_backup_key),
|
||||
style = MaterialTheme.typography.headlineMedium.copy(color = MaterialTheme.colorScheme.onSurface),
|
||||
modifier = Modifier.padding(top = 40.dp, bottom = 16.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
)
|
||||
},
|
||||
seeKeyButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
keyboardController?.hide()
|
||||
onForgotKeyClick()
|
||||
}
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.VerifyBackupPinScreen__forgot_key))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun VerifyBackupKeyScreen() {
|
||||
Previews.Preview {
|
||||
VerifyBackupPinScreen(
|
||||
backupKey = (0 until 64).map { Random.nextInt(65..90).toChar() }.joinToString("").uppercase()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,10 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) {
|
||||
|
||||
private const val KEY_MEDIA_ROOT_BACKUP_KEY = "backup.mediaRootBackupKey"
|
||||
|
||||
private const val KEY_LAST_VERIFY_KEY_TIME = "backup.last_verify_key_time"
|
||||
private const val KEY_HAS_SNOOZED_VERIFY = "backup.has_snoozed_verify"
|
||||
private const val KEY_HAS_VERIFIED_BEFORE = "backup.has_verified_before"
|
||||
|
||||
private val cachedCdnCredentialsExpiresIn: Duration = 12.hours
|
||||
|
||||
private val lock = ReentrantLock()
|
||||
@@ -299,6 +303,9 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) {
|
||||
.putLong(KEY_NEXT_BACKUP_TIME, -1)
|
||||
.putBoolean(KEY_BACKUPS_INITIALIZED, false)
|
||||
.putBoolean(KEY_BACKUP_UPLOADED, false)
|
||||
.putLong(KEY_LAST_VERIFY_KEY_TIME, -1)
|
||||
.putBoolean(KEY_HAS_VERIFIED_BEFORE, false)
|
||||
.putBoolean(KEY_HAS_SNOOZED_VERIFY, false)
|
||||
.apply()
|
||||
backupTier = null
|
||||
backupTierInternalOverride = null
|
||||
@@ -324,6 +331,15 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) {
|
||||
|
||||
var isNoBackupForManualUploadNotified by booleanValue(KEY_MANUAL_NO_BACKUP_NOTIFIED, false)
|
||||
|
||||
/** Last time they successfully entered their backup key, including when they first initialized backups **/
|
||||
var lastVerifyKeyTime by longValue(KEY_LAST_VERIFY_KEY_TIME, -1)
|
||||
|
||||
/** Checks if they have previously snoozed the megaphone to verify their backup key **/
|
||||
var hasSnoozedVerified by booleanValue(KEY_HAS_SNOOZED_VERIFY, false)
|
||||
|
||||
/** Checks if they have ever verified their backup key before **/
|
||||
var hasVerifiedBefore by booleanValue(KEY_HAS_VERIFIED_BEFORE, false)
|
||||
|
||||
/**
|
||||
* If true, it means we have been told that remote storage is full, but we have not yet run any of our "garbage collection" tasks, like committing deletes
|
||||
* or pruning orphaned media.
|
||||
|
||||
@@ -36,6 +36,7 @@ class LogSectionRemoteBackups : LogSection {
|
||||
output.append("Backup frequency: ${SignalStore.backup.backupFrequency.name}\n")
|
||||
output.append("Optimize storage: ${SignalStore.backup.optimizeStorage}\n")
|
||||
output.append("Detected subscription state mismatch: ${SignalStore.backup.subscriptionStateMismatchDetected}\n")
|
||||
output.append("Last verified key time: ${SignalStore.backup.lastVerifyKeyTime}\n")
|
||||
output.append("\n -- Subscription State\n")
|
||||
|
||||
val backupSubscriptionId = InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.BACKUP)
|
||||
|
||||
@@ -19,6 +19,7 @@ import org.signal.core.util.SetUtil;
|
||||
import org.signal.core.util.TranslationDetection;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.verify.VerifyBackupKeyActivity;
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.MegaphoneRecord;
|
||||
@@ -128,6 +129,7 @@ public final class Megaphones {
|
||||
put(Event.ADD_A_PROFILE_PHOTO, shouldShowAddAProfilePhotoMegaphone(context) ? ALWAYS : NEVER);
|
||||
put(Event.PNP_LAUNCH, shouldShowPnpLaunchMegaphone() ? ALWAYS : NEVER);
|
||||
put(Event.TURN_ON_SIGNAL_BACKUPS, shouldShowTurnOnBackupsMegaphone(context) ? ALWAYS : NEVER);
|
||||
put(Event.VERIFY_BACKUP_KEY, new VerifyBackupKeyReminderSchedule());
|
||||
}};
|
||||
}
|
||||
|
||||
@@ -177,6 +179,8 @@ public final class Megaphones {
|
||||
return buildUpdatePinAfterAepRegistrationMegaphone();
|
||||
case TURN_ON_SIGNAL_BACKUPS:
|
||||
return buildTurnOnSignalBackupsMegaphone();
|
||||
case VERIFY_BACKUP_KEY:
|
||||
return buildVerifyBackupKeyMegaphone();
|
||||
default:
|
||||
throw new IllegalArgumentException("Event not handled!");
|
||||
}
|
||||
@@ -474,6 +478,28 @@ public final class Megaphones {
|
||||
.build();
|
||||
}
|
||||
|
||||
public static @NonNull Megaphone buildVerifyBackupKeyMegaphone() {
|
||||
Megaphone.Builder builder = new Megaphone.Builder(Event.VERIFY_BACKUP_KEY, Megaphone.Style.BASIC)
|
||||
.setImage(R.drawable.image_signal_backups_key)
|
||||
.setTitle(R.string.VerifyBackupKey__title)
|
||||
.setBody(R.string.VerifyBackupKey__body)
|
||||
.setActionButton(R.string.VerifyBackupKey__verify, (megaphone, controller) -> {
|
||||
Intent intent = VerifyBackupKeyActivity.createIntent(controller.getMegaphoneActivity());
|
||||
|
||||
controller.onMegaphoneNavigationRequested(intent, VerifyBackupKeyActivity.REQUEST_CODE);
|
||||
});
|
||||
|
||||
if (!SignalStore.backup().getHasSnoozedVerified()) {
|
||||
builder.setSecondaryButton(R.string.VerifyBackupKey__not_now, (megaphone, controller) -> {
|
||||
SignalStore.backup().setHasSnoozedVerified(true);
|
||||
controller.onMegaphoneToastRequested(controller.getMegaphoneActivity().getString(R.string.VerifyBackupKey__we_will_ask_again));
|
||||
controller.onMegaphoneSnooze(Event.VERIFY_BACKUP_KEY);
|
||||
});
|
||||
}
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
private static boolean shouldShowOnboardingMegaphone(@NonNull Context context) {
|
||||
return SignalStore.onboarding().hasOnboarding(context);
|
||||
}
|
||||
@@ -599,7 +625,8 @@ public final class Megaphones {
|
||||
GRANT_FULL_SCREEN_INTENT("grant_full_screen_intent"),
|
||||
NEW_LINKED_DEVICE("new_linked_device"),
|
||||
UPDATE_PIN_AFTER_AEP_REGISTRATION("update_pin_after_registration"),
|
||||
TURN_ON_SIGNAL_BACKUPS("turn_on_signal_backups");
|
||||
TURN_ON_SIGNAL_BACKUPS("turn_on_signal_backups"),
|
||||
VERIFY_BACKUP_KEY("verify_backup_key");
|
||||
|
||||
private final String key;
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package org.thoughtcrime.securesms.megaphone
|
||||
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import kotlin.time.Duration.Companion.days
|
||||
|
||||
/**
|
||||
* Calculates if the verify key megaphone should be shown based on the following rules
|
||||
* - 1 reminder within 14 days of creation, every 6 months after that
|
||||
* - Allow snooze only once, for a week
|
||||
* - Do not show within 1 week of showing the PIN reminder
|
||||
*/
|
||||
class VerifyBackupKeyReminderSchedule : MegaphoneSchedule {
|
||||
|
||||
override fun shouldDisplay(seenCount: Int, lastSeen: Long, firstVisible: Long, currentTime: Long): Boolean {
|
||||
if (!RemoteConfig.messageBackups) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!SignalStore.backup.areBackupsEnabled) {
|
||||
return false
|
||||
}
|
||||
|
||||
val lastVerifiedTime = SignalStore.backup.lastVerifyKeyTime
|
||||
val previouslySnoozed = SignalStore.backup.hasSnoozedVerified
|
||||
val isFirstReminder = !SignalStore.backup.hasVerifiedBefore
|
||||
|
||||
val intervalTime = if (isFirstReminder) 14.days.inWholeMilliseconds else 183.days.inWholeMilliseconds
|
||||
val snoozedTime = if (previouslySnoozed) 7.days.inWholeMilliseconds else 0.days.inWholeMilliseconds
|
||||
|
||||
val shouldShowBackupKeyReminder = System.currentTimeMillis() > (lastVerifiedTime + intervalTime + snoozedTime)
|
||||
val hasShownPinReminderRecently = System.currentTimeMillis() < SignalStore.pin.lastReminderTime + 7.days.inWholeMilliseconds
|
||||
|
||||
return shouldShowBackupKeyReminder && !hasShownPinReminderRecently
|
||||
}
|
||||
}
|
||||
@@ -7738,6 +7738,19 @@
|
||||
<!-- Button of a megaphone shown at the bottom of the chat list to prompt the user to enable message backups that will simply dismiss the megaphone -->
|
||||
<string name="TurnOnSignalBackups__not_now">Not now</string>
|
||||
|
||||
<!-- Title of a megaphone shown to prompt the user to verify their backup key -->
|
||||
<string name="VerifyBackupKey__title">Verify your backup key</string>
|
||||
<!-- Body of a megaphone shown to prompt the user to verify their backup key -->
|
||||
<string name="VerifyBackupKey__body">To help you remember your key, we\'ll periodically ask you for it.</string>
|
||||
<!-- Button of a megaphone that will take users to the verify backup key screen -->
|
||||
<string name="VerifyBackupKey__verify">Verify</string>
|
||||
<!-- Button of a megaphone that will snooze the reminder to verify their backup key -->
|
||||
<string name="VerifyBackupKey__not_now">Not now</string>
|
||||
<!-- Snackbar text shown when the backup key entered is correct. -->
|
||||
<string name="VerifyBackupKey__backup_key_correct">Backup key correct. Keep your key safe.</string>
|
||||
<!-- Snackbar text shown when the user chooses to snooze the backup key reminder. -->
|
||||
<string name="VerifyBackupKey__we_will_ask_again">We will ask you again in a week.</string>
|
||||
|
||||
<!-- Title of a megaphone shown at the bottom of the chat list when a user has disable the system setting for showing full screen notifications used showing incoming calls -->
|
||||
<string name="GrantFullScreenIntentPermission_megaphone_title">Turn on full screen notifications?</string>
|
||||
<!-- Body of a megaphone shown at the bottom of the chat list when a user has disable the system setting for showing full screen notifications used showing incoming calls -->
|
||||
@@ -8402,6 +8415,13 @@
|
||||
<!-- Confirm your key button text to move onto next screen in flow -->
|
||||
<string name="MessageBackupsKeyVerifyScreen__next">Next</string>
|
||||
|
||||
<!-- Verify your key title -->
|
||||
<string name="VerifyBackupPinScreen__enter_your_backup_key">Enter your backup key</string>
|
||||
<!-- Verify your key subtitle -->
|
||||
<string name="VerifyBackupPinScreen__enter_the_backup_key_that_you_recorded">Enter the 64-digit code you recorded when you enabled backups.</string>
|
||||
<!-- Button text when you forget your backup key -->
|
||||
<string name="VerifyBackupPinScreen__forgot_key">Forgot key?</string>
|
||||
|
||||
<!-- MessagesBackupsTypeSelectionScreen -->
|
||||
<!-- Screen headline -->
|
||||
<string name="MessagesBackupsTypeSelectionScreen__choose_your_backup_plan">Choose your backup plan</string>
|
||||
|
||||
Reference in New Issue
Block a user