Revamp restore decisions state and flesh out post registration restore options.

This commit is contained in:
Cody Henthorne
2025-02-04 13:26:36 -05:00
committed by Greyson Parrelli
parent b78747fda2
commit fe44789d88
35 changed files with 1071 additions and 411 deletions

View File

@@ -17,6 +17,7 @@ import org.thoughtcrime.securesms.BaseActivity
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.isDecisionPending
import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity
import org.thoughtcrime.securesms.pin.PinRestoreActivity
import org.thoughtcrime.securesms.profiles.AvatarHelper
@@ -88,7 +89,7 @@ class RegistrationActivity : BaseActivity() {
val nextIntent: Intent? = when {
needsPin -> CreateSvrPinActivity.getIntentForPinCreate(this@RegistrationActivity)
!SignalStore.registration.hasSkippedTransferOrRestore() && RemoteConfig.messageBackups -> RemoteRestoreActivity.getIntent(this@RegistrationActivity)
SignalStore.registration.restoreDecisionState.isDecisionPending && RemoteConfig.messageBackups -> RemoteRestoreActivity.getIntent(this@RegistrationActivity)
needsProfile -> CreateProfileActivity.getIntentForUserProfile(this@RegistrationActivity)
else -> null
}

View File

@@ -23,5 +23,6 @@ enum class RegistrationCheckpoint {
PIN_ENTERED,
VERIFICATION_CODE_VALIDATED,
SERVICE_REGISTRATION_COMPLETED,
BACKUP_TIER_NOT_RESTORED,
LOCAL_REGISTRATION_COMPLETE
}

View File

@@ -14,6 +14,7 @@ import com.google.i18n.phonenumbers.PhoneNumberUtil
import com.google.i18n.phonenumbers.Phonenumber
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
@@ -26,6 +27,7 @@ import org.signal.core.util.Stopwatch
import org.signal.core.util.isNotNullOrBlank
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.database.model.databaseprotos.RestoreDecisionState
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.MultiDeviceProfileContentUpdateJob
import org.thoughtcrime.securesms.jobs.MultiDeviceProfileKeyUpdateJob
@@ -33,7 +35,13 @@ import org.thoughtcrime.securesms.jobs.ProfileUploadJob
import org.thoughtcrime.securesms.jobs.ReclaimUsernameAndLinkJob
import org.thoughtcrime.securesms.jobs.StorageAccountRestoreJob
import org.thoughtcrime.securesms.jobs.StorageSyncJob
import org.thoughtcrime.securesms.keyvalue.NewAccount
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.Skipped
import org.thoughtcrime.securesms.keyvalue.Start
import org.thoughtcrime.securesms.keyvalue.intendToRestore
import org.thoughtcrime.securesms.keyvalue.isDecisionPending
import org.thoughtcrime.securesms.keyvalue.isWantingManualRemoteRestore
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.pin.SvrRepository
import org.thoughtcrime.securesms.pin.SvrWrongPinException
@@ -80,6 +88,7 @@ import java.io.IOException
import java.nio.charset.StandardCharsets
import java.util.concurrent.TimeUnit
import kotlin.jvm.optionals.getOrNull
import kotlin.math.max
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
@@ -863,9 +872,9 @@ class RegistrationViewModel : ViewModel() {
SignalStore.registration.localRegistrationMetadata = metadata
RegistrationRepository.registerAccountLocally(context, metadata)
if (!remoteResult.storageCapable && !SignalStore.registration.hasCompletedRestore()) {
if (!remoteResult.storageCapable && SignalStore.registration.restoreDecisionState.isDecisionPending) {
// Not being storage capable is a high signal that account is new and there's no data to restore
SignalStore.registration.markSkippedTransferOrRestore()
SignalStore.registration.restoreDecisionState = RestoreDecisionState.NewAccount
}
if (reglockEnabled || SignalStore.svr.hasOptedInWithAccess()) {
@@ -892,13 +901,59 @@ class RegistrationViewModel : ViewModel() {
refreshRemoteConfig()
val checkpoint = if (SignalStore.registration.restoreDecisionState.isDecisionPending &&
SignalStore.registration.restoreDecisionState.isWantingManualRemoteRestore &&
!SignalStore.backup.isBackupTierRestored
) {
RegistrationCheckpoint.BACKUP_TIER_NOT_RESTORED
} else {
RegistrationCheckpoint.LOCAL_REGISTRATION_COMPLETE
}
store.update {
it.copy(
registrationCheckpoint = RegistrationCheckpoint.LOCAL_REGISTRATION_COMPLETE
registrationCheckpoint = checkpoint
)
}
}
fun resetRestoreDecision() {
SignalStore.registration.restoreDecisionState = RestoreDecisionState.Start
}
fun intendToRestore(hasOldDevice: Boolean, fromRemote: Boolean? = null) {
SignalStore.registration.restoreDecisionState = RestoreDecisionState.intendToRestore(hasOldDevice, fromRemote)
}
fun skipRestoreAfterRegistration() {
SignalStore.registration.restoreDecisionState = RestoreDecisionState.Skipped
store.update {
it.copy(registrationCheckpoint = RegistrationCheckpoint.LOCAL_REGISTRATION_COMPLETE)
}
}
fun restoreBackupTier() {
store.update {
it.copy(registrationCheckpoint = RegistrationCheckpoint.SERVICE_REGISTRATION_COMPLETED)
}
viewModelScope.launch {
val start = System.currentTimeMillis()
val tierUnknown = BackupRepository.restoreBackupTier(SignalStore.account.requireAci()) == null
delay(max(0L, 500L - (System.currentTimeMillis() - start)))
if (tierUnknown) {
store.update {
it.copy(registrationCheckpoint = RegistrationCheckpoint.BACKUP_TIER_NOT_RESTORED)
}
} else {
store.update {
it.copy(registrationCheckpoint = RegistrationCheckpoint.LOCAL_REGISTRATION_COMPLETE)
}
}
}
}
fun completeRegistration() {
AppDependencies.jobManager.startChain(ProfileUploadJob()).then(listOf(MultiDeviceProfileKeyUpdateJob(), MultiDeviceProfileContentUpdateJob())).enqueue()
RegistrationUtil.maybeMarkRegistrationComplete()

View File

@@ -0,0 +1,66 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registrationv3.ui.restore
import org.thoughtcrime.securesms.restore.enterbackupkey.PostRegistrationEnterBackupKeyViewModel
import org.whispersystems.signalservice.api.AccountEntropyPool
/**
* Help verify a potential string could be an [AccountEntropyPool] string. Intended only
* for use in [EnterBackupKeyViewModel] and [PostRegistrationEnterBackupKeyViewModel].
*/
object AccountEntropyPoolVerification {
/**
* Given a backup key and metadata around it's previous verification state, provide an updated or new state.
*
* @param backupKey key to verify
* @param changed if the key has changed from the previous verification attempt
* @param previousAEPValidationError the error if any of the previous verification attempt
* @return [Pair] of is contents generally valid and any still present or new validation error
*/
fun verifyAEP(backupKey: String, changed: Boolean, previousAEPValidationError: AEPValidationError?): Pair<Boolean, AEPValidationError?> {
val isValid = validateContents(backupKey)
val isShort = backupKey.length < AccountEntropyPool.LENGTH
val isExact = backupKey.length == AccountEntropyPool.LENGTH
var updatedError: AEPValidationError? = checkErrorStillApplies(backupKey, previousAEPValidationError, isShort || isExact, isValid, changed)
if (updatedError == null) {
updatedError = checkForNewError(backupKey, isShort, isExact, isValid)
}
return isValid to updatedError
}
private fun validateContents(backupKey: String): Boolean {
return AccountEntropyPool.isFullyValid(backupKey)
}
private fun checkErrorStillApplies(backupKey: String, error: AEPValidationError?, isShortOrExact: Boolean, isValid: Boolean, isChanged: Boolean): AEPValidationError? {
return when (error) {
is AEPValidationError.TooLong -> if (isShortOrExact) null else error.copy(count = backupKey.length)
AEPValidationError.Invalid -> if (isValid) null else error
AEPValidationError.Incorrect -> if (isChanged) null else error
null -> null
}
}
private fun checkForNewError(backupKey: String, isShort: Boolean, isExact: Boolean, isValid: Boolean): AEPValidationError? {
return if (!isShort && !isExact) {
AEPValidationError.TooLong(backupKey.length, AccountEntropyPool.LENGTH)
} else if (!isValid && isExact) {
AEPValidationError.Invalid
} else {
null
}
}
sealed interface AEPValidationError {
data class TooLong(val count: Int, val max: Int) : AEPValidationError
data object Invalid : AEPValidationError
data object Incorrect : AEPValidationError
}
}

View File

@@ -5,51 +5,11 @@
package org.thoughtcrime.securesms.registrationv3.ui.restore
import android.graphics.Typeface
import android.os.Bundle
import android.view.View
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
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.remember
import androidx.compose.runtime.rememberCoroutineScope
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.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 androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
@@ -57,24 +17,17 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.signal.core.ui.BottomSheets
import org.signal.core.ui.Buttons
import org.signal.core.ui.Dialogs
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.signal.core.ui.horizontalGutters
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.ui.BackupsIconColors
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationState
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationCheckpoint
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel
import org.thoughtcrime.securesms.registrationv3.ui.phonenumber.EnterPhoneNumberMode
import org.thoughtcrime.securesms.registrationv3.ui.restore.EnterBackupKeyViewModel.EnterBackupKeyState
import org.thoughtcrime.securesms.registrationv3.ui.shared.RegistrationScreen
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.navigation.safeNavigate
@@ -105,6 +58,17 @@ class EnterBackupKeyFragment : ComposeFragment() {
}
}
}
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.CREATED) {
sharedViewModel
.state
.filter { it.registrationCheckpoint == RegistrationCheckpoint.BACKUP_TIER_NOT_RESTORED }
.collect {
viewModel.handleBackupTierNotRestored()
}
}
}
}
@Composable
@@ -114,8 +78,10 @@ class EnterBackupKeyFragment : ComposeFragment() {
EnterBackupKeyScreen(
backupKey = viewModel.backupKey,
state = state,
sharedState = sharedState,
inProgress = sharedState.inProgress,
isBackupKeyValid = state.backupKeyValid,
chunkLength = state.chunkLength,
aepValidationError = state.aepValidationError,
onBackupKeyChanged = viewModel::updateBackupKey,
onNextClicked = {
viewModel.registering()
@@ -126,276 +92,64 @@ class EnterBackupKeyFragment : ComposeFragment() {
pin = null
)
},
onRegistrationErrorDismiss = viewModel::clearRegistrationError,
onBackupKeyHelp = { CommunicationActions.openBrowserLink(requireContext(), LEARN_MORE_URL) },
onLearnMore = { CommunicationActions.openBrowserLink(requireContext(), LEARN_MORE_URL) },
onSkip = { findNavController().safeNavigate(EnterBackupKeyFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.RESTART_AFTER_COLLECTION)) }
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun EnterBackupKeyScreen(
backupKey: String,
state: EnterBackupKeyState,
sharedState: RegistrationState,
onBackupKeyChanged: (String) -> Unit = {},
onRegistrationErrorDismiss: () -> Unit = {},
onBackupKeyHelp: () -> Unit = {},
onNextClicked: () -> Unit = {},
onLearnMore: () -> Unit = {},
onSkip: () -> Unit = {}
) {
val coroutineScope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
RegistrationScreen(
title = stringResource(R.string.EnterBackupKey_title),
subtitle = stringResource(R.string.EnterBackupKey_subtitle),
bottomContent = {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
TextButton(
enabled = !sharedState.inProgress,
onClick = {
coroutineScope.launch {
sheetState.show()
}
}
) {
Text(
text = stringResource(id = R.string.EnterBackupKey_no_backup_key)
)
}
AnimatedContent(
targetState = state.isRegistering,
label = "next-progress"
) { isRegistering ->
if (isRegistering) {
CircularProgressIndicator(
modifier = Modifier.size(40.dp)
)
} else {
Buttons.LargeTonal(
enabled = state.backupKeyValid && state.aepValidationError == null,
onClick = onNextClicked
) {
Text(
text = stringResource(id = R.string.RegistrationActivity_next)
)
}
}
}
}
}
) {
val focusRequester = remember { FocusRequester() }
val visualTransform = remember(state.chunkLength) { BackupKeyVisualTransformation(chunkSize = state.chunkLength) }
val keyboardController = LocalSoftwareKeyboardController.current
TextField(
value = backupKey,
onValueChange = onBackupKeyChanged,
label = {
Text(text = stringResource(id = R.string.EnterBackupKey_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 (state.backupKeyValid) {
keyboardController?.hide()
onNextClicked()
}
}
),
supportingText = { state.aepValidationError?.ValidationErrorMessage() },
isError = state.aepValidationError != null,
minLines = 4,
visualTransformation = visualTransform,
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
)
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
if (sheetState.isVisible) {
ModalBottomSheet(
dragHandle = null,
onDismissRequest = {
coroutineScope.launch {
sheetState.hide()
}
}
) {
NoBackupKeyBottomSheet(
onLearnMore = {
coroutineScope.launch {
sheetState.hide()
}
onLearnMore()
},
onSkip = onSkip
)
}
}
if (state.showRegistrationError) {
if (state.registerAccountResult is RegisterAccountResult.IncorrectRecoveryPassword) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.EnterBackupKey_incorrect_backup_key_title),
body = stringResource(R.string.EnterBackupKey_incorrect_backup_key_message),
confirm = stringResource(R.string.EnterBackupKey_try_again),
dismiss = stringResource(R.string.EnterBackupKey_backup_key_help),
onConfirm = {},
onDeny = onBackupKeyHelp,
onDismiss = onRegistrationErrorDismiss
)
} else {
val message = when (state.registerAccountResult) {
is RegisterAccountResult.RateLimited -> stringResource(R.string.RegistrationActivity_you_have_made_too_many_attempts_please_try_again_later)
else -> stringResource(R.string.RegistrationActivity_error_connecting_to_service)
}
Dialogs.SimpleMessageDialog(
message = message,
onDismiss = onRegistrationErrorDismiss,
dismiss = stringResource(android.R.string.ok)
)
}
}
}
}
@Composable
private fun EnterBackupKeyViewModel.AEPValidationError.ValidationErrorMessage() {
when (this) {
is EnterBackupKeyViewModel.AEPValidationError.TooLong -> Text(text = stringResource(R.string.EnterBackupKey_too_long_error, this.count, this.max))
EnterBackupKeyViewModel.AEPValidationError.Invalid -> Text(text = stringResource(R.string.EnterBackupKey_invalid_backup_key_error))
EnterBackupKeyViewModel.AEPValidationError.Incorrect -> Text(text = stringResource(R.string.EnterBackupKey_incorrect_backup_key_error))
}
}
@SignalPreview
@Composable
private fun EnterBackupKeyScreenPreview() {
Previews.Preview {
EnterBackupKeyScreen(
backupKey = "UY38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t",
state = EnterBackupKeyState(requiredLength = 64, chunkLength = 4),
sharedState = RegistrationState(phoneNumber = null, recoveryPassword = null)
)
}
}
@SignalPreview
@Composable
private fun EnterBackupKeyScreenErrorPreview() {
Previews.Preview {
EnterBackupKeyScreen(
backupKey = "UY38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t",
state = EnterBackupKeyState(requiredLength = 64, chunkLength = 4, aepValidationError = EnterBackupKeyViewModel.AEPValidationError.Invalid),
sharedState = RegistrationState(phoneNumber = null, recoveryPassword = null)
)
}
}
@Composable
private fun NoBackupKeyBottomSheet(
onLearnMore: () -> Unit = {},
onSkip: () -> Unit = {}
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.horizontalGutters()
) {
BottomSheets.Handle()
Icon(
painter = painterResource(id = R.drawable.symbol_key_24),
tint = BackupsIconColors.Success.foreground,
contentDescription = null,
modifier = Modifier
.padding(top = 18.dp, bottom = 16.dp)
.size(88.dp)
.background(
color = BackupsIconColors.Success.background,
shape = CircleShape
)
.padding(20.dp)
)
Text(
text = stringResource(R.string.EnterBackupKey_no_backup_key),
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(R.string.EnterBackupKey_no_key_paragraph_1),
style = MaterialTheme.typography.bodyMedium.copy(textAlign = TextAlign.Center),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(R.string.EnterBackupKey_no_key_paragraph_1),
style = MaterialTheme.typography.bodyMedium.copy(textAlign = TextAlign.Center),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(36.dp))
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 24.dp)
) {
TextButton(
onClick = onLearnMore
) {
Text(
text = stringResource(id = R.string.EnterBackupKey_learn_more)
)
}
TextButton(
onClick = onSkip
) {
Text(
text = stringResource(id = R.string.EnterBackupKey_skip_and_dont_restore)
)
}
ErrorContent(
state = state,
onBackupTierRetry = { sharedViewModel.restoreBackupTier() },
onSkipRestoreAfterRegistration = sharedViewModel::skipRestoreAfterRegistration,
onBackupTierNotRestoredDismiss = viewModel::hideRestoreBackupKeyFailed,
onRegistrationErrorDismiss = viewModel::clearRegistrationError,
onBackupKeyHelp = { CommunicationActions.openBrowserLink(requireContext(), LEARN_MORE_URL) }
)
}
}
}
@SignalPreview
@Composable
private fun NoBackupKeyBottomSheetPreview() {
Previews.BottomSheetPreview {
NoBackupKeyBottomSheet()
private fun ErrorContent(
state: EnterBackupKeyViewModel.EnterBackupKeyState,
onBackupTierRetry: () -> Unit = {},
onSkipRestoreAfterRegistration: () -> Unit = {},
onBackupTierNotRestoredDismiss: () -> Unit = {},
onRegistrationErrorDismiss: () -> Unit = {},
onBackupKeyHelp: () -> Unit = {}
) {
if (state.showBackupTierNotRestoreError) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.EnterBackupKey_backup_not_found),
body = stringResource(R.string.EnterBackupKey_backup_key_you_entered_is_correct_but_no_backup),
confirm = stringResource(R.string.EnterBackupKey_try_again),
dismiss = stringResource(R.string.EnterBackupKey_skip_restore),
onConfirm = onBackupTierRetry,
onDeny = onSkipRestoreAfterRegistration,
onDismiss = onBackupTierNotRestoredDismiss
)
} else if (state.showRegistrationError) {
if (state.registerAccountResult is RegisterAccountResult.IncorrectRecoveryPassword) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.EnterBackupKey_incorrect_backup_key_title),
body = stringResource(R.string.EnterBackupKey_incorrect_backup_key_message),
confirm = stringResource(R.string.EnterBackupKey_try_again),
dismiss = stringResource(R.string.EnterBackupKey_backup_key_help),
onConfirm = {},
onDeny = onBackupKeyHelp,
onDismiss = onRegistrationErrorDismiss
)
} else {
val message = when (state.registerAccountResult) {
is RegisterAccountResult.RateLimited -> stringResource(R.string.RegistrationActivity_you_have_made_too_many_attempts_please_try_again_later)
else -> stringResource(R.string.RegistrationActivity_error_connecting_to_service)
}
Dialogs.SimpleMessageDialog(
message = message,
onDismiss = onRegistrationErrorDismiss,
dismiss = stringResource(android.R.string.ok)
)
}
}
}

View File

@@ -0,0 +1,310 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registrationv3.ui.restore
import android.graphics.Typeface
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
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.remember
import androidx.compose.runtime.rememberCoroutineScope
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.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.BottomSheets
import org.signal.core.ui.Buttons
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.signal.core.ui.horizontalGutters
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.ui.BackupsIconColors
import org.thoughtcrime.securesms.registrationv3.ui.shared.RegistrationScreen
import org.whispersystems.signalservice.api.AccountEntropyPool
/**
* Shared screen infrastructure for entering an [AccountEntropyPool].
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EnterBackupKeyScreen(
backupKey: String,
inProgress: Boolean,
isBackupKeyValid: Boolean,
chunkLength: Int,
aepValidationError: AccountEntropyPoolVerification.AEPValidationError?,
onBackupKeyChanged: (String) -> Unit = {},
onNextClicked: () -> Unit = {},
onLearnMore: () -> Unit = {},
onSkip: () -> Unit = {},
errorContent: @Composable () -> Unit
) {
val coroutineScope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
RegistrationScreen(
title = stringResource(R.string.EnterBackupKey_title),
subtitle = stringResource(R.string.EnterBackupKey_subtitle),
bottomContent = {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
TextButton(
enabled = !inProgress,
onClick = {
coroutineScope.launch {
sheetState.show()
}
}
) {
Text(
text = stringResource(id = R.string.EnterBackupKey_no_backup_key)
)
}
AnimatedContent(
targetState = inProgress,
label = "next-progress"
) { inProgress ->
if (inProgress) {
CircularProgressIndicator(
modifier = Modifier.size(40.dp)
)
} else {
Buttons.LargeTonal(
enabled = isBackupKeyValid && aepValidationError == null,
onClick = onNextClicked
) {
Text(
text = stringResource(id = R.string.RegistrationActivity_next)
)
}
}
}
}
}
) {
val focusRequester = remember { FocusRequester() }
val visualTransform = remember(chunkLength) { BackupKeyVisualTransformation(chunkSize = chunkLength) }
val keyboardController = LocalSoftwareKeyboardController.current
TextField(
value = backupKey,
onValueChange = onBackupKeyChanged,
label = {
Text(text = stringResource(id = R.string.EnterBackupKey_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()
onNextClicked()
}
}
),
supportingText = { aepValidationError?.ValidationErrorMessage() },
isError = aepValidationError != null,
minLines = 4,
visualTransformation = visualTransform,
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
)
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
if (sheetState.isVisible) {
ModalBottomSheet(
dragHandle = null,
onDismissRequest = {
coroutineScope.launch {
sheetState.hide()
}
}
) {
NoBackupKeyBottomSheet(
onLearnMore = {
coroutineScope.launch {
sheetState.hide()
}
onLearnMore()
},
onSkip = onSkip
)
}
}
errorContent()
}
}
@Composable
private fun AccountEntropyPoolVerification.AEPValidationError.ValidationErrorMessage() {
when (this) {
is AccountEntropyPoolVerification.AEPValidationError.TooLong -> Text(text = stringResource(R.string.EnterBackupKey_too_long_error, this.count, this.max))
AccountEntropyPoolVerification.AEPValidationError.Invalid -> Text(text = stringResource(R.string.EnterBackupKey_invalid_backup_key_error))
AccountEntropyPoolVerification.AEPValidationError.Incorrect -> Text(text = stringResource(R.string.EnterBackupKey_incorrect_backup_key_error))
}
}
@SignalPreview
@Composable
private fun EnterBackupKeyScreenPreview() {
Previews.Preview {
EnterBackupKeyScreen(
backupKey = "UY38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t",
isBackupKeyValid = true,
inProgress = false,
chunkLength = 4,
aepValidationError = null
) {}
}
}
@SignalPreview
@Composable
private fun EnterBackupKeyScreenErrorPreview() {
Previews.Preview {
EnterBackupKeyScreen(
backupKey = "UY38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t",
isBackupKeyValid = true,
inProgress = false,
chunkLength = 4,
aepValidationError = AccountEntropyPoolVerification.AEPValidationError.Invalid
) {}
}
}
@Composable
private fun NoBackupKeyBottomSheet(
onLearnMore: () -> Unit = {},
onSkip: () -> Unit = {}
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.horizontalGutters()
) {
BottomSheets.Handle()
Icon(
painter = painterResource(id = R.drawable.symbol_key_24),
tint = BackupsIconColors.Success.foreground,
contentDescription = null,
modifier = Modifier
.padding(top = 18.dp, bottom = 16.dp)
.size(88.dp)
.background(
color = BackupsIconColors.Success.background,
shape = CircleShape
)
.padding(20.dp)
)
Text(
text = stringResource(R.string.EnterBackupKey_no_backup_key),
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(R.string.EnterBackupKey_no_key_paragraph_1),
style = MaterialTheme.typography.bodyMedium.copy(textAlign = TextAlign.Center),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(R.string.EnterBackupKey_no_key_paragraph_1),
style = MaterialTheme.typography.bodyMedium.copy(textAlign = TextAlign.Center),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(36.dp))
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 24.dp)
) {
TextButton(
onClick = onLearnMore
) {
Text(
text = stringResource(id = R.string.EnterBackupKey_learn_more)
)
}
TextButton(
onClick = onSkip
) {
Text(
text = stringResource(id = R.string.EnterBackupKey_skip_and_dont_restore)
)
}
}
}
}
@SignalPreview
@Composable
private fun NoBackupKeyBottomSheetPreview() {
Previews.BottomSheetPreview {
NoBackupKeyBottomSheet()
}
}

View File

@@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.update
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
import org.thoughtcrime.securesms.registrationv3.ui.restore.AccountEntropyPoolVerification.AEPValidationError
import org.whispersystems.signalservice.api.AccountEntropyPool
class EnterBackupKeyViewModel : ViewModel() {
@@ -36,46 +37,19 @@ class EnterBackupKeyViewModel : ViewModel() {
val state: StateFlow<EnterBackupKeyState> = store
fun updateBackupKey(key: String) {
val newKey = AccountEntropyPool.removeIllegalCharacters(key).lowercase()
val newKey = AccountEntropyPool.removeIllegalCharacters(key).take(AccountEntropyPool.LENGTH + 16).lowercase()
val changed = newKey != backupKey
backupKey = newKey
store.update {
val isValid = validateContents(backupKey)
val isShort = backupKey.length < it.requiredLength
val isExact = backupKey.length == it.requiredLength
var updatedError: AEPValidationError? = checkErrorStillApplies(it.aepValidationError, isShort || isExact, isValid, changed)
if (updatedError == null) {
updatedError = checkForNewError(isShort, isExact, isValid, it.requiredLength)
}
val (isValid, updatedError) = AccountEntropyPoolVerification.verifyAEP(
backupKey = backupKey,
changed = changed,
previousAEPValidationError = it.aepValidationError
)
it.copy(backupKeyValid = isValid, aepValidationError = updatedError)
}
}
private fun validateContents(backupKey: String): Boolean {
return AccountEntropyPool.isFullyValid(backupKey)
}
private fun checkErrorStillApplies(error: AEPValidationError?, isShortOrExact: Boolean, isValid: Boolean, isChanged: Boolean): AEPValidationError? {
return when (error) {
is AEPValidationError.TooLong -> if (isShortOrExact) null else error.copy(count = backupKey.length)
AEPValidationError.Invalid -> if (isValid) null else error
AEPValidationError.Incorrect -> if (isChanged) null else error
null -> null
}
}
private fun checkForNewError(isShort: Boolean, isExact: Boolean, isValid: Boolean, requiredLength: Int): AEPValidationError? {
return if (!isShort && !isExact) {
AEPValidationError.TooLong(backupKey.length, requiredLength)
} else if (!isValid && isExact) {
AEPValidationError.Invalid
} else {
null
}
}
fun registering() {
store.update { it.copy(isRegistering = true) }
}
@@ -111,19 +85,30 @@ class EnterBackupKeyViewModel : ViewModel() {
}
}
fun handleBackupTierNotRestored() {
store.update {
it.copy(
showBackupTierNotRestoreError = true
)
}
}
fun hideRestoreBackupKeyFailed() {
store.update {
it.copy(
showBackupTierNotRestoreError = false
)
}
}
data class EnterBackupKeyState(
val backupKeyValid: Boolean = false,
val requiredLength: Int,
val chunkLength: Int,
val isRegistering: Boolean = false,
val showRegistrationError: Boolean = false,
val showBackupTierNotRestoreError: Boolean = false,
val registerAccountResult: RegisterAccountResult? = null,
val aepValidationError: AEPValidationError? = null
)
sealed interface AEPValidationError {
data class TooLong(val count: Int, val max: Int) : AEPValidationError
data object Invalid : AEPValidationError
data object Incorrect : AEPValidationError
}
}

View File

@@ -22,13 +22,16 @@ import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.backup.v2.RestoreV2Event
import org.thoughtcrime.securesms.database.model.databaseprotos.RestoreDecisionState
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.JobTracker
import org.thoughtcrime.securesms.jobs.BackupRestoreJob
import org.thoughtcrime.securesms.jobs.BackupRestoreMediaJob
import org.thoughtcrime.securesms.jobs.ProfileUploadJob
import org.thoughtcrime.securesms.jobs.SyncArchivedMediaJob
import org.thoughtcrime.securesms.keyvalue.Completed
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.Skipped
import org.thoughtcrime.securesms.registration.util.RegistrationUtil
import org.thoughtcrime.securesms.registrationv3.data.QuickRegistrationRepository
import org.thoughtcrime.securesms.registrationv3.data.RegistrationRepository
@@ -103,7 +106,7 @@ class RemoteRestoreViewModel(isOnlyRestoreOption: Boolean) : ViewModel() {
when (state) {
JobTracker.JobState.SUCCESS -> {
Log.i(TAG, "Restore successful")
SignalStore.registration.markRestoreCompleted()
SignalStore.registration.restoreDecisionState = RestoreDecisionState.Completed
if (!RegistrationRepository.isMissingProfileData()) {
RegistrationUtil.maybeMarkRegistrationComplete()
@@ -135,7 +138,7 @@ class RemoteRestoreViewModel(isOnlyRestoreOption: Boolean) : ViewModel() {
}
fun cancel() {
SignalStore.registration.markSkippedTransferOrRestore()
SignalStore.registration.restoreDecisionState = RestoreDecisionState.Skipped
}
fun clearError() {
@@ -143,7 +146,7 @@ class RemoteRestoreViewModel(isOnlyRestoreOption: Boolean) : ViewModel() {
}
fun skipRestore() {
SignalStore.registration.markSkippedTransferOrRestore()
SignalStore.registration.restoreDecisionState = RestoreDecisionState.Skipped
viewModelScope.launch {
withContext(Dispatchers.IO) {

View File

@@ -53,8 +53,14 @@ class SelectManualRestoreMethodFragment : ComposeFragment() {
private fun startRestoreMethod(method: RestoreMethod) {
when (method) {
RestoreMethod.FROM_SIGNAL_BACKUPS -> findNavController().safeNavigate(SelectManualRestoreMethodFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.COLLECT_FOR_MANUAL_SIGNAL_BACKUPS_RESTORE))
RestoreMethod.FROM_LOCAL_BACKUP_V1 -> launchRestoreActivity.launch(RestoreActivity.getLocalRestoreIntent(requireContext()))
RestoreMethod.FROM_SIGNAL_BACKUPS -> {
sharedViewModel.intendToRestore(hasOldDevice = false, fromRemote = true)
findNavController().safeNavigate(SelectManualRestoreMethodFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.COLLECT_FOR_MANUAL_SIGNAL_BACKUPS_RESTORE))
}
RestoreMethod.FROM_LOCAL_BACKUP_V1 -> {
sharedViewModel.intendToRestore(hasOldDevice = false, fromRemote = false)
launchRestoreActivity.launch(RestoreActivity.getLocalRestoreIntent(requireContext()))
}
RestoreMethod.FROM_OLD_DEVICE -> error("Device transfer not supported in manual restore flow")
RestoreMethod.FROM_LOCAL_BACKUP_V2 -> error("Not currently supported")
}

View File

@@ -83,6 +83,7 @@ class WelcomeFragment : LoggingFragment(R.layout.fragment_registration_welcome_v
}
private fun navigateToNextScreenViaContinue() {
sharedViewModel.resetRestoreDecision()
sharedViewModel.maybePrefillE164(requireContext())
findNavController().safeNavigate(WelcomeFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.NORMAL))
}
@@ -109,8 +110,14 @@ class WelcomeFragment : LoggingFragment(R.layout.fragment_registration_welcome_v
when (userSelection) {
WelcomeUserSelection.CONTINUE -> throw IllegalArgumentException()
WelcomeUserSelection.RESTORE_WITH_OLD_PHONE -> findNavController().safeNavigate(WelcomeFragmentDirections.goToRestoreViaQr())
WelcomeUserSelection.RESTORE_WITH_NO_PHONE -> findNavController().safeNavigate(WelcomeFragmentDirections.goToSelectRestoreMethod(userSelection))
WelcomeUserSelection.RESTORE_WITH_OLD_PHONE -> {
sharedViewModel.intendToRestore(hasOldDevice = true, fromRemote = true)
findNavController().safeNavigate(WelcomeFragmentDirections.goToRestoreViaQr())
}
WelcomeUserSelection.RESTORE_WITH_NO_PHONE -> {
sharedViewModel.intendToRestore(hasOldDevice = false, fromRemote = true)
findNavController().safeNavigate(WelcomeFragmentDirections.goToSelectRestoreMethod(userSelection))
}
}
}