Add verify AEP screen.

This commit is contained in:
Cody Henthorne
2025-03-21 11:26:49 -04:00
parent 82bb18e218
commit cef839d300
8 changed files with 348 additions and 144 deletions

View File

@@ -111,7 +111,7 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
val context = LocalContext.current
MessageBackupsKeyRecordScreen(
backupKey = state.accountEntropyPool.value,
backupKey = state.accountEntropyPool.displayValue,
onNavigationClick = viewModel::goToPreviousStage,
onNextClick = viewModel::goToNextStage,
onCopyToClipboardClick = {
@@ -120,6 +120,14 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
)
}
composable(route = MessageBackupsStage.Route.BACKUP_KEY_VERIFY.name) {
MessageBackupsKeyVerifyScreen(
backupKey = state.accountEntropyPool.displayValue,
onNavigationClick = viewModel::goToPreviousStage,
onNextClick = viewModel::goToNextStage
)
}
composable(route = MessageBackupsStage.Route.TYPE_SELECTION.name) {
MessageBackupsTypeSelectionScreen(
stage = state.stage,
@@ -140,7 +148,7 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
val currentRoute = navController.currentDestination?.route
if (currentRoute != newRoute) {
if (currentRoute != null && MessageBackupsStage.Route.valueOf(currentRoute).isAfter(state.stage.route)) {
navController.popBackStack()
navController.popBackStack(newRoute, inclusive = false)
} else {
navController.navigate(newRoute)
}

View File

@@ -164,7 +164,8 @@ class MessageBackupsFlowViewModel(
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.TYPE_SELECTION)
MessageBackupsStage.BACKUP_KEY_RECORD -> 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)
MessageBackupsStage.CREATING_IN_APP_PAYMENT -> error("This is driven by an async coroutine.")
@@ -186,6 +187,7 @@ 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.TYPE_SELECTION -> MessageBackupsStage.BACKUP_KEY_RECORD
MessageBackupsStage.CHECKOUT_SHEET -> MessageBackupsStage.TYPE_SELECTION
MessageBackupsStage.CREATING_IN_APP_PAYMENT -> MessageBackupsStage.CREATING_IN_APP_PAYMENT

View File

@@ -7,33 +7,20 @@ package org.thoughtcrime.securesms.backup.v2.ui.subscription
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.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
@@ -43,8 +30,6 @@ import androidx.compose.ui.text.font.FontWeight
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
@@ -59,7 +44,6 @@ import org.signal.core.ui.R as CoreUiR
* Screen displaying the backup key allowing the user to write it down
* or copy it.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MessageBackupsKeyRecordScreen(
backupKey: String,
@@ -67,11 +51,6 @@ fun MessageBackupsKeyRecordScreen(
onCopyToClipboardClick: (String) -> Unit = {},
onNextClick: () -> Unit = {}
) {
val coroutineScope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
val backupKeyString = remember(backupKey) {
backupKey.chunked(4).joinToString(" ")
}
@@ -164,11 +143,7 @@ fun MessageBackupsKeyRecordScreen(
.padding(bottom = 24.dp)
) {
Buttons.LargeTonal(
onClick = {
coroutineScope.launch {
sheetState.show()
}
},
onClick = onNextClick,
modifier = Modifier.align(Alignment.BottomEnd)
) {
Text(
@@ -177,111 +152,6 @@ fun MessageBackupsKeyRecordScreen(
}
}
}
if (sheetState.isVisible) {
ModalBottomSheet(
dragHandle = null,
onDismissRequest = {
coroutineScope.launch {
sheetState.hide()
}
}
) {
BottomSheetContent(
onContinueClick = onNextClick,
onSeeKeyAgainClick = {
coroutineScope.launch {
sheetState.hide()
}
}
)
}
}
}
}
@Composable
private fun BottomSheetContent(
onContinueClick: () -> Unit,
onSeeKeyAgainClick: () -> Unit
) {
var checked by remember { mutableStateOf(false) }
LazyColumn(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = dimensionResource(CoreUiR.dimen.gutter))
.testTag("message-backups-key-record-screen-sheet-content")
) {
item {
BottomSheets.Handle()
}
item {
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__keep_your_key_safe),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 30.dp)
)
}
item {
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__signal_will_not),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 12.dp)
)
}
item {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(vertical = 24.dp)
.defaultMinSize(minWidth = 220.dp)
.clip(shape = RoundedCornerShape(percent = 50))
.clickable(onClick = { checked = !checked })
) {
Checkbox(
checked = checked,
onCheckedChange = { checked = it }
)
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__ive_recorded_my_key),
style = MaterialTheme.typography.bodyLarge
)
}
}
item {
Buttons.LargeTonal(
enabled = checked,
onClick = onContinueClick,
modifier = Modifier
.padding(bottom = 16.dp)
.defaultMinSize(minWidth = 220.dp)
) {
Text(text = stringResource(R.string.MessageBackupsKeyRecordScreen__continue))
}
}
item {
TextButton(
onClick = onSeeKeyAgainClick,
modifier = Modifier
.padding(bottom = 24.dp)
.defaultMinSize(minWidth = 220.dp)
) {
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__see_key_again)
)
}
}
}
}
@@ -294,11 +164,3 @@ private fun MessageBackupsKeyRecordScreenPreview() {
)
}
}
@SignalPreview
@Composable
private fun BottomSheetContentPreview() {
Previews.BottomSheetPreview {
BottomSheetContent({}, {})
}
}

View File

@@ -0,0 +1,314 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import android.graphics.Typeface
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.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.rememberModalBottomSheetState
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.rememberCoroutineScope
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.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.text.font.FontFamily
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.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.registrationv3.ui.restore.BackupKeyVisualTransformation
import org.whispersystems.signalservice.api.AccountEntropyPool
import kotlin.random.Random
import kotlin.random.nextInt
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)
@Composable
fun MessageBackupsKeyVerifyScreen(
backupKey: String,
onNavigationClick: () -> Unit = {},
onNextClick: () -> Unit = {}
) {
val coroutineScope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
Scaffolds.Settings(
title = stringResource(R.string.MessageBackupsKeyVerifyScreen__confirm_your_backup_key),
navigationIconPainter = painterResource(R.drawable.symbol_arrow_left_24),
onNavigationClick = onNavigationClick
) { paddingValues ->
Column(
verticalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.padding(paddingValues)
.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
) {
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))
TextField(
value = enteredBackupKey,
onValueChange = {
enteredBackupKey = AccountEntropyPool.removeIllegalCharacters(it).uppercase()
isBackupKeyValid = enteredBackupKey == backupKey
showError = !isBackupKeyValid && enteredBackupKey.length >= backupKey.length
},
label = {
Text(text = stringResource(id = R.string.MessageBackupsKeyVerifyScreen__backup_key))
},
textStyle = LocalTextStyle.current.copy(
fontFamily = FontFamily(typeface = Typeface.MONOSPACE),
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() }
}
}
),
supportingText = { if (showError) Text(text = stringResource(R.string.MessageBackupsKeyVerifyScreen__incorrect_backup_key)) },
isError = showError,
minLines = 4,
visualTransformation = visualTransform,
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
)
}
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
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()
) {
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)
)
}
}
}
}
if (sheetState.isVisible) {
ModalBottomSheet(
sheetState = sheetState,
dragHandle = null,
onDismissRequest = {
coroutineScope.launch {
sheetState.hide()
}
}
) {
BottomSheetContent(
onContinueClick = {
coroutineScope.launch {
sheetState.hide()
}
onNextClick()
},
onSeeKeyAgainClick = {
coroutineScope.launch {
sheetState.hide()
}
onNavigationClick()
}
)
}
}
}
}
@Composable
private fun BottomSheetContent(
onContinueClick: () -> Unit,
onSeeKeyAgainClick: () -> Unit
) {
LazyColumn(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = dimensionResource(CoreUiR.dimen.gutter))
.testTag("message-backups-key-record-screen-sheet-content")
) {
item {
BottomSheets.Handle()
}
item {
Image(
painter = painterResource(R.drawable.image_signal_backups_key),
contentDescription = null,
modifier = Modifier
.padding(top = 26.dp)
.size(80.dp)
)
}
item {
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__keep_your_key_safe),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 16.dp)
)
}
item {
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__signal_will_not),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 12.dp)
)
}
item {
Spacer(modifier = Modifier.height(54.dp))
Buttons.LargeTonal(
onClick = onContinueClick,
modifier = Modifier
.padding(bottom = 16.dp)
.defaultMinSize(minWidth = 220.dp)
) {
Text(text = stringResource(R.string.MessageBackupsKeyRecordScreen__continue))
}
}
item {
TextButton(
onClick = onSeeKeyAgainClick,
modifier = Modifier
.padding(bottom = 24.dp)
.defaultMinSize(minWidth = 220.dp)
) {
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__see_key_again)
)
}
}
}
}
@SignalPreview
@Composable
private fun MessageBackupsKeyRecordScreenPreview() {
Previews.Preview {
MessageBackupsKeyVerifyScreen(
backupKey = (0 until 64).map { Random.nextInt(65..90).toChar() }.joinToString("").uppercase()
)
}
}
@SignalPreview
@Composable
private fun BottomSheetContentPreview() {
Previews.BottomSheetPreview {
BottomSheetContent({}, {})
}
}

View File

@@ -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_VERIFY(route = Route.BACKUP_KEY_VERIFY),
TYPE_SELECTION(route = Route.TYPE_SELECTION),
CREATING_IN_APP_PAYMENT(route = Route.TYPE_SELECTION),
CHECKOUT_SHEET(route = Route.TYPE_SELECTION),
@@ -31,6 +32,7 @@ enum class MessageBackupsStage(
EDUCATION,
BACKUP_KEY_EDUCATION,
BACKUP_KEY_RECORD,
BACKUP_KEY_VERIFY,
TYPE_SELECTION;
fun isAfter(other: Route): Boolean = ordinal > other.ordinal

View File

@@ -19,7 +19,7 @@ class BackupKeyDisplayFragment : ComposeFragment() {
@Composable
override fun FragmentContent() {
MessageBackupsKeyRecordScreen(
backupKey = SignalStore.account.accountEntropyPool.value,
backupKey = SignalStore.account.accountEntropyPool.displayValue,
onNavigationClick = { findNavController().popBackStack() },
onCopyToClipboardClick = { Util.copyToClipboard(requireContext(), it) },
onNextClick = { findNavController().popBackStack() }

View File

@@ -8140,6 +8140,19 @@
<!-- Sheet secondary action button label -->
<string name="MessageBackupsKeyRecordScreen__see_key_again">See key again</string>
<!-- Confirm key title -->
<string name="MessageBackupsKeyVerifyScreen__confirm_your_backup_key">Confirm your backup key</string>
<!-- Confirm your key subtitle -->
<string name="MessageBackupsKeyVerifyScreen__enter_the_backup_key_that_you_just_recorded">Enter the backup key that you just recorded</string>
<!-- Confirm your key text entry hint text -->
<string name="MessageBackupsKeyVerifyScreen__backup_key">Backup key</string>
<!-- Confirm your key text entry error text -->
<string name="MessageBackupsKeyVerifyScreen__incorrect_backup_key">Incorrect backup key</string>
<!-- Confirm your key button text to return to record key screen. -->
<string name="MessageBackupsKeyVerifyScreen__see_key_again">See key again</string>
<!-- Confirm your key button text to move onto next screen in flow -->
<string name="MessageBackupsKeyVerifyScreen__next">Next</string>
<!-- MessagesBackupsTypeSelectionScreen -->
<!-- Screen headline -->
<string name="MessagesBackupsTypeSelectionScreen__choose_your_backup_plan">Choose your backup plan</string>

View File

@@ -12,7 +12,10 @@ import org.signal.libsignal.messagebackup.AccountEntropyPool as LibSignalAccount
/**
* The Root of All Entropy. You can use this to derive the [MasterKey] or [MessageBackupKey].
*/
class AccountEntropyPool(val value: String) {
class AccountEntropyPool(value: String) {
val value = value.lowercase()
val displayValue = value.uppercase()
companion object {
private val INVALID_CHARACTERS = Regex("[^0-9a-zA-Z]")