Prompt users to backup during quick restore.

This commit is contained in:
Greyson Parrelli
2026-01-14 10:51:29 -05:00
committed by Alex Hart
parent 4921198cd8
commit 9012a2afc0
26 changed files with 1049 additions and 245 deletions

View File

@@ -68,7 +68,7 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
AppSettingsRoute.LinkDeviceRoute.LinkDevice -> AppSettingsFragmentDirections.actionDirectToDevices()
AppSettingsRoute.UsernameLinkRoute.UsernameLink -> AppSettingsFragmentDirections.actionDirectToUsernameLinkSettings()
is AppSettingsRoute.AccountRoute.Username -> AppSettingsFragmentDirections.actionDirectToUsernameRecovery()
is AppSettingsRoute.BackupsRoute.Remote -> AppSettingsFragmentDirections.actionDirectToRemoteBackupsSettingsFragment()
is AppSettingsRoute.BackupsRoute.Remote -> AppSettingsFragmentDirections.actionDirectToRemoteBackupsSettingsFragment().setForQuickRestore(appSettingsRoute.forQuickRestore)
AppSettingsRoute.ChatFoldersRoute.ChatFolders -> AppSettingsFragmentDirections.actionDirectToChatFoldersFragment()
is AppSettingsRoute.ChatFoldersRoute.CreateChatFolders -> AppSettingsFragmentDirections.actionDirectToCreateFoldersFragment(
appSettingsRoute.folderId,
@@ -204,7 +204,8 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
fun usernameRecovery(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.AccountRoute.Username(mode = UsernameEditMode.RECOVERY))
@JvmStatic
fun remoteBackups(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.BackupsRoute.Remote())
@JvmOverloads
fun remoteBackups(context: Context, forQuickRestore: Boolean = false): Intent = getIntentForStartLocation(context, AppSettingsRoute.BackupsRoute.Remote(forQuickRestore = forQuickRestore))
@JvmStatic
fun chatFolders(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.ChatFoldersRoute.ChatFolders)

View File

@@ -37,11 +37,13 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
@@ -49,6 +51,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -71,6 +74,7 @@ import androidx.fragment.app.setFragmentResultListener
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Dialogs
@@ -113,6 +117,7 @@ import org.thoughtcrime.securesms.compose.StatusBarColorNestedScrollConnection
import org.thoughtcrime.securesms.help.HelpFragment
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.protos.ArchiveUploadProgressState
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.DateUtils
@@ -186,7 +191,7 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
}
override fun onBackupNowClick() {
viewModel.onBackupNowClick()
viewModel.onBackupNowClick(args.forQuickRestore)
}
override fun onTurnOffAndDeleteBackupsClick() {
@@ -295,6 +300,10 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
override fun onFreeTierBackupSizeLearnMore() {
CommunicationActions.openBrowserLink(requireContext(), "https://support.signal.org/hc/articles/9708267671322")
}
override fun onTransferScanQrCodeClick() {
startActivity(MediaSelectionActivity.camera(context!!))
}
}
private fun displayBackupKey() {
@@ -400,6 +409,7 @@ private interface ContentCallbacks {
fun onIncludeDebuglogClick(newState: Boolean) = Unit
fun onMediaBackupSizeClick() = Unit
fun onFreeTierBackupSizeLearnMore() = Unit
fun onTransferScanQrCodeClick() = Unit
object Empty : ContentCallbacks
}
@@ -668,6 +678,13 @@ private fun RemoteBackupsSettingsContent(
onDismiss = contentCallbacks::onDialogDismissed
)
}
RemoteBackupsSettingsState.Dialog.READY_TO_TRANSFER -> {
ReadyToTransferBottomSheet(
onScanQrCodeClick = contentCallbacks::onTransferScanQrCodeClick,
onDismiss = contentCallbacks::onDialogDismissed
)
}
}
val snackbarMessageId = remember(state.snackbar) {
@@ -1699,6 +1716,90 @@ private fun ResumeRestoreOverCellularDialog(
)
}
/**
* Bottom sheet displayed when the device is ready to transfer data to a new device.
* Shows a QR code illustration and prompts the user to scan the QR code on the target device.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ReadyToTransferBottomSheet(
onScanQrCodeClick: () -> Unit = {},
onDismiss: () -> Unit = {}
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
sheetState = sheetState,
onDismissRequest = onDismiss,
dragHandle = { BottomSheets.Handle() }
) {
ReadyToTransferContent(
onScanQrCodeClick = onScanQrCodeClick,
onCancelClick = onDismiss
)
}
}
@Composable
private fun ReadyToTransferContent(
onScanQrCodeClick: () -> Unit = {},
onCancelClick: () -> Unit = {}
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 40.dp)
) {
Spacer(modifier = Modifier.size(16.dp))
Image(
painter = painterResource(R.drawable.illustration_scan_qr_transfer),
contentDescription = null,
modifier = Modifier.size(192.dp)
)
Spacer(modifier = Modifier.size(24.dp))
Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__ready_to_transfer),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier = Modifier.horizontalGutters()
)
Spacer(modifier = Modifier.size(8.dp))
Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__use_this_device_to_scan_qr),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.horizontalGutters()
)
Spacer(modifier = Modifier.size(36.dp))
Buttons.LargeTonal(
onClick = onScanQrCodeClick,
modifier = Modifier
.defaultMinSize(minWidth = 220.dp)
) {
Text(text = stringResource(R.string.RemoteBackupsSettingsFragment__scan_qr_code))
}
TextButton(
onClick = onCancelClick,
modifier = Modifier.padding(top = 8.dp)
) {
Text(
text = stringResource(android.R.string.cancel),
color = MaterialTheme.colorScheme.primary
)
}
}
}
@Composable
private fun BackupReadyToDownloadRow(
ready: BackupRestoreState.Ready,
@@ -2156,6 +2257,14 @@ private fun SkipDownloadDialogPreview() {
}
}
@DayNightPreviews
@Composable
private fun ReadyToTransferContentPreview() {
Previews.BottomSheetPreview {
ReadyToTransferContent()
}
}
@DayNightPreviews
@Composable
private fun BackupDeletionCardPreview() {

View File

@@ -55,7 +55,8 @@ data class RemoteBackupsSettingsState(
CANCEL_MEDIA_RESTORE_PROTECTION,
RESTORE_OVER_CELLULAR_PROTECTION,
FREE_TIER_MEDIA_EXPLAINER,
KEY_ROTATION_LIMIT_REACHED
KEY_ROTATION_LIMIT_REACHED,
READY_TO_TRANSFER
}
enum class Snackbar {

View File

@@ -88,6 +88,8 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
val state: StateFlow<RemoteBackupsSettingsState> = _state
val restoreState: StateFlow<BackupRestoreState> = _restoreState
private var forQuickRestore = false
init {
ArchiveUploadProgress.triggerUpdate()
@@ -172,6 +174,10 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
.collect { current ->
if (previous != null && previous != current.state && current.state == ArchiveUploadProgressState.State.None) {
Log.d(TAG, "Refreshing state after archive upload.")
if (forQuickRestore) {
Log.d(TAG, "Backup completed with the forQuickRestore flag on. Refreshing state.")
_state.value = _state.value.copy(dialog = RemoteBackupsSettingsState.Dialog.READY_TO_TRANSFER)
}
refreshState(null)
}
previous = current.state
@@ -275,8 +281,9 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
}
}
fun onBackupNowClick() {
fun onBackupNowClick(forQuickRestore: Boolean) {
BackupMessagesJob.enqueue()
this.forQuickRestore = forQuickRestore
}
fun cancelUpload() {

View File

@@ -65,7 +65,7 @@ sealed interface AppSettingsRoute : Parcelable {
sealed interface BackupsRoute : AppSettingsRoute {
data object Backups : BackupsRoute
data object Local : BackupsRoute
data class Remote(val backupLaterSelected: Boolean = false) : BackupsRoute
data class Remote(val backupLaterSelected: Boolean = false, val forQuickRestore: Boolean = false) : BackupsRoute
data object DisplayKey : BackupsRoute
}

View File

@@ -20,7 +20,7 @@ import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionNavigator
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel
import org.thoughtcrime.securesms.mms.MediaConstraints
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.registration.olddevice.TransferAccountActivity
import org.thoughtcrime.securesms.registration.olddevice.QuickTransferOldDeviceActivity
import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.navigation.safeNavigate
@@ -102,7 +102,7 @@ class MediaCaptureFragment : Fragment(R.layout.fragment_container), CameraFragme
}
is MediaCaptureEvent.ReregistrationScannedFromQrCode -> {
startActivity(TransferAccountActivity.intent(requireContext(), event.data))
startActivity(QuickTransferOldDeviceActivity.intent(requireContext(), event.data))
requireActivity().finish()
}
}

View File

@@ -0,0 +1,188 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.olddevice
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResultLauncher
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.launch
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.BiometricDeviceAuthentication
import org.thoughtcrime.securesms.BiometricDeviceLockContract
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.PassphraseRequiredActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.compose.SignalTheme
import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceTransferActivity
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
import org.thoughtcrime.securesms.util.DynamicTheme
import org.thoughtcrime.securesms.util.viewModel
import org.whispersystems.signalservice.api.provisioning.RestoreMethod
/**
* Launched after scanning QR code from new device to start the transfer/reregistration process from
* old phone to new phone.
*/
class QuickTransferOldDeviceActivity : PassphraseRequiredActivity() {
companion object {
private val TAG = Log.tag(QuickTransferOldDeviceActivity::class)
private const val KEY_URI = "URI"
const val LEARN_MORE_URL = "https://support.signal.org/hc/articles/360007059752-Backup-and-Restore-Messages"
fun intent(context: Context, uri: String): Intent {
return Intent(context, QuickTransferOldDeviceActivity::class.java).apply {
putExtra(KEY_URI, uri)
}
}
}
private val theme: DynamicTheme = DynamicNoActionBarTheme()
private val viewModel: QuickTransferOldDeviceViewModel by viewModel {
QuickTransferOldDeviceViewModel(intent.getStringExtra(KEY_URI)!!)
}
private lateinit var biometricAuth: BiometricDeviceAuthentication
private lateinit var biometricDeviceLockLauncher: ActivityResultLauncher<String>
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
super.onCreate(savedInstanceState, ready)
theme.onCreate(this)
if (!SignalStore.account.isRegistered) {
finish()
}
biometricDeviceLockLauncher = registerForActivityResult(BiometricDeviceLockContract()) { result: Int ->
if (result == BiometricDeviceAuthentication.AUTHENTICATED) {
Log.i(TAG, "Device authentication succeeded via contract")
continueTransferOrPromptForBackup()
}
}
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setAllowedAuthenticators(BiometricDeviceAuthentication.ALLOWED_AUTHENTICATORS)
.setTitle(getString(R.string.TransferAccount_unlock_to_transfer))
.setConfirmationRequired(true)
.build()
biometricAuth = BiometricDeviceAuthentication(
BiometricManager.from(this),
BiometricPrompt(this, BiometricAuthenticationListener()),
promptInfo
)
lifecycleScope.launch {
val restoreMethodSelected = viewModel
.state
.mapNotNull { it.restoreMethodSelected }
.firstOrNull()
when (restoreMethodSelected) {
RestoreMethod.DEVICE_TRANSFER -> {
startActivities(
arrayOf(
MainActivity.clearTop(this@QuickTransferOldDeviceActivity),
Intent(this@QuickTransferOldDeviceActivity, OldDeviceTransferActivity::class.java)
)
)
}
RestoreMethod.REMOTE_BACKUP,
RestoreMethod.LOCAL_BACKUP,
RestoreMethod.DECLINE,
null -> startActivity(MainActivity.clearTop(this@QuickTransferOldDeviceActivity))
}
}
setContent {
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(state.performAuthentication) {
if (state.performAuthentication) {
authenticate()
viewModel.clearAttemptAuthentication()
}
}
LaunchedEffect(state.navigateToBackupCreation) {
if (state.navigateToBackupCreation) {
startActivity(AppSettingsActivity.remoteBackups(context = this@QuickTransferOldDeviceActivity, forQuickRestore = true))
viewModel.clearNavigateToBackupCreation()
}
}
SignalTheme {
CompositionLocalProvider(LocalNavigationEventDispatcherOwner provides this) {
TransferAccountNavHost(
viewModel = viewModel,
onFinished = { finish() }
)
}
}
}
}
override fun onPause() {
super.onPause()
biometricAuth.cancelAuthentication()
}
override fun onResume() {
super.onResume()
theme.onResume(this)
}
private fun authenticate() {
val canAuthenticate = biometricAuth.authenticate(this, true) {
biometricDeviceLockLauncher.launch(getString(R.string.TransferAccount_unlock_to_transfer))
}
if (!canAuthenticate) {
Log.w(TAG, "Device authentication not available")
continueTransferOrPromptForBackup()
}
}
private fun continueTransferOrPromptForBackup() {
Log.d(TAG, "transferAccount()")
viewModel.onTransferAccountAttempted()
}
private inner class BiometricAuthenticationListener : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(errorCode: Int, errorString: CharSequence) {
Log.w(TAG, "Device authentication error: $errorCode")
onAuthenticationFailed()
}
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
Log.i(TAG, "Device authentication succeeded")
continueTransferOrPromptForBackup()
}
override fun onAuthenticationFailed() {
Log.w(TAG, "Device authentication failed")
}
}
}

View File

@@ -0,0 +1,104 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.olddevice
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberDecoratedNavEntries
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
import org.signal.core.ui.navigation.TransitionSpecs
import org.thoughtcrime.securesms.registration.olddevice.preparedevice.PrepareDeviceScreen
import org.thoughtcrime.securesms.registration.olddevice.transferaccount.TransferAccountScreen
/**
* Navigation routes for the transfer account flow.
*/
@Parcelize
sealed interface TransferAccountRoute : NavKey, Parcelable {
@Serializable
data object Transfer : TransferAccountRoute
@Serializable
data object PrepareDevice : TransferAccountRoute
@Serializable
data object Done : TransferAccountRoute
}
/**
* Navigation host for the transfer account flow.
*/
@Composable
fun TransferAccountNavHost(
viewModel: QuickTransferOldDeviceViewModel,
modifier: Modifier = Modifier,
onFinished: () -> Unit
) {
val backStack by viewModel.backStack.collectAsStateWithLifecycle()
val entryProvider = entryProvider {
navigationEntries(
viewModel = viewModel,
onFinished = onFinished
)
}
val decorators = listOf(
rememberSaveableStateHolderNavEntryDecorator<NavKey>()
)
val entries = rememberDecoratedNavEntries(
backStack = backStack,
entryDecorators = decorators,
entryProvider = entryProvider
)
NavDisplay(
entries = entries,
onBack = { viewModel.goBack() },
modifier = modifier,
transitionSpec = TransitionSpecs.HorizontalSlide.transitionSpec,
popTransitionSpec = TransitionSpecs.HorizontalSlide.popTransitionSpec,
predictivePopTransitionSpec = TransitionSpecs.HorizontalSlide.predictivePopTransitonSpec
)
}
private fun EntryProviderScope<NavKey>.navigationEntries(
viewModel: QuickTransferOldDeviceViewModel,
onFinished: () -> Unit
) {
entry<TransferAccountRoute.Transfer> {
val state by viewModel.state.collectAsStateWithLifecycle()
TransferAccountScreen(
state = state,
emitter = { viewModel.onEvent(it) }
)
}
entry<TransferAccountRoute.PrepareDevice> {
val state by viewModel.state.collectAsStateWithLifecycle()
PrepareDeviceScreen(
state = state,
emitter = { viewModel.onEvent(it) }
)
}
entry<TransferAccountRoute.Done> {
LaunchedEffect(Unit) {
onFinished()
}
}
}

View File

@@ -0,0 +1,19 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.olddevice
import org.thoughtcrime.securesms.registration.data.QuickRegistrationRepository
import org.whispersystems.signalservice.api.provisioning.RestoreMethod
data class QuickTransferOldDeviceState(
val reRegisterUri: String,
val inProgress: Boolean = false,
val reRegisterResult: QuickRegistrationRepository.TransferAccountResult? = null,
val restoreMethodSelected: RestoreMethod? = null,
val navigateToBackupCreation: Boolean = false,
val lastBackupTimestamp: Long = 0,
val performAuthentication: Boolean = false
)

View File

@@ -0,0 +1,117 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.olddevice
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.registration.data.QuickRegistrationRepository
import org.thoughtcrime.securesms.registration.olddevice.QuickTransferOldDeviceState
import org.thoughtcrime.securesms.registration.olddevice.preparedevice.PrepareDeviceScreenEvents
import org.thoughtcrime.securesms.registration.olddevice.transferaccount.TransferScreenEvents
import org.whispersystems.signalservice.api.provisioning.RestoreMethod
import java.util.UUID
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
class QuickTransferOldDeviceViewModel(reRegisterUri: String) : ViewModel() {
companion object {
private val TAG = Log.tag(QuickTransferOldDeviceViewModel::class)
}
private val store: MutableStateFlow<QuickTransferOldDeviceState> = MutableStateFlow(
QuickTransferOldDeviceState(
reRegisterUri = reRegisterUri,
lastBackupTimestamp = SignalStore.backup.lastBackupTime
)
)
val state: StateFlow<QuickTransferOldDeviceState> = store
private val _backStack: MutableStateFlow<List<TransferAccountRoute>> = MutableStateFlow(listOf(TransferAccountRoute.Transfer))
val backStack: StateFlow<List<TransferAccountRoute>> = _backStack
fun goBack() {
_backStack.update { it.dropLast(1) }
}
fun onEvent(event: PrepareDeviceScreenEvents) {
when (event) {
PrepareDeviceScreenEvents.BackUpNow -> {
store.update { it.copy(navigateToBackupCreation = true) }
}
PrepareDeviceScreenEvents.NavigateBack -> {
_backStack.update { it.dropLast(1) }
}
PrepareDeviceScreenEvents.SkipAndContinue -> {
_backStack.update { listOf(TransferAccountRoute.Transfer) }
transferAccount()
}
}
}
fun onEvent(event: TransferScreenEvents) {
when (event) {
TransferScreenEvents.ContinueOnOtherDeviceDismiss -> {
_backStack.update { listOf(TransferAccountRoute.Done) }
}
TransferScreenEvents.ErrorDialogDismissed -> {
store.update { it.copy(reRegisterResult = null) }
}
TransferScreenEvents.NavigateBack -> {
_backStack.update { listOf(TransferAccountRoute.Done) }
}
TransferScreenEvents.TransferClicked -> {
store.update { it.copy(performAuthentication = true) }
}
}
}
fun onTransferAccountAttempted() {
val timeSinceLastBackup = (System.currentTimeMillis() - store.value.lastBackupTimestamp).milliseconds
if (timeSinceLastBackup > 30.minutes) {
Log.i(TAG, "It's been $timeSinceLastBackup since the last backup. Prompting user to back up now.")
_backStack.update { it + TransferAccountRoute.PrepareDevice }
} else {
Log.i(TAG, "It's been $timeSinceLastBackup since the last backup. We can continue without prompting.")
transferAccount()
}
}
fun clearAttemptAuthentication() {
store.update { it.copy(performAuthentication = false) }
}
fun clearNavigateToBackupCreation() {
store.update { it.copy(navigateToBackupCreation = false) }
}
private fun transferAccount() {
viewModelScope.launch(Dispatchers.IO) {
val restoreMethodToken = UUID.randomUUID().toString()
store.update { it.copy(inProgress = true) }
val result = QuickRegistrationRepository.transferAccount(store.value.reRegisterUri, restoreMethodToken)
store.update { it.copy(reRegisterResult = result, inProgress = false) }
if (result == QuickRegistrationRepository.TransferAccountResult.SUCCESS) {
val restoreMethod = QuickRegistrationRepository.waitForRestoreMethodSelectionOnNewDevice(restoreMethodToken)
if (restoreMethod != RestoreMethod.DECLINE) {
SignalStore.Companion.registration.restoringOnNewDevice = true
}
store.update { it.copy(restoreMethodSelected = restoreMethod) }
}
}
}
}

View File

@@ -1,55 +0,0 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.olddevice
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.registration.data.QuickRegistrationRepository
import org.whispersystems.signalservice.api.provisioning.RestoreMethod
import java.util.UUID
class TransferAccountViewModel(reRegisterUri: String) : ViewModel() {
private val store: MutableStateFlow<TransferAccountState> = MutableStateFlow(TransferAccountState(reRegisterUri))
val state: StateFlow<TransferAccountState> = store
fun transferAccount() {
viewModelScope.launch(Dispatchers.IO) {
val restoreMethodToken = UUID.randomUUID().toString()
store.update { it.copy(inProgress = true) }
val result = QuickRegistrationRepository.transferAccount(store.value.reRegisterUri, restoreMethodToken)
store.update { it.copy(reRegisterResult = result, inProgress = false) }
if (result == QuickRegistrationRepository.TransferAccountResult.SUCCESS) {
val restoreMethod = QuickRegistrationRepository.waitForRestoreMethodSelectionOnNewDevice(restoreMethodToken)
if (restoreMethod != RestoreMethod.DECLINE) {
SignalStore.registration.restoringOnNewDevice = true
}
store.update { it.copy(restoreMethodSelected = restoreMethod) }
}
}
}
fun clearReRegisterResult() {
store.update { it.copy(reRegisterResult = null) }
}
data class TransferAccountState(
val reRegisterUri: String,
val inProgress: Boolean = false,
val reRegisterResult: QuickRegistrationRepository.TransferAccountResult? = null,
val restoreMethodSelected: RestoreMethod? = null
)
}

View File

@@ -0,0 +1,142 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.olddevice.preparedevice
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
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.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.horizontalGutters
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.registration.olddevice.QuickTransferOldDeviceState
import org.thoughtcrime.securesms.util.DateUtils
import java.util.Locale
@Composable
fun PrepareDeviceScreen(
state: QuickTransferOldDeviceState,
emitter: (PrepareDeviceScreenEvents) -> Unit
) {
Scaffolds.Default(
onNavigationClick = { emitter(PrepareDeviceScreenEvents.NavigateBack) },
navigationIconRes = R.drawable.symbol_arrow_start_24
) { contentPadding ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxSize()
.padding(contentPadding)
.verticalScroll(rememberScrollState())
.horizontalGutters()
) {
Text(
text = stringResource(R.string.PrepareDevice_title),
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(R.string.PrepareDevice_body),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.weight(1f))
Image(
painter = painterResource(R.drawable.illustration_prepare_backup),
contentDescription = null,
modifier = Modifier.width(120.dp)
)
Spacer(modifier = Modifier.height(24.dp))
if (state.lastBackupTimestamp > 0) {
val context = LocalContext.current
val dateString = DateUtils.getDateTimeString(context, Locale.getDefault(), state.lastBackupTimestamp)
Text(
text = stringResource(R.string.PrepareDevice_last_backup_description, dateString),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
Spacer(modifier = Modifier.weight(1f))
Buttons.LargeTonal(
onClick = { emitter(PrepareDeviceScreenEvents.BackUpNow) },
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(R.string.PrepareDevice_back_up_now))
}
TextButton(
onClick = { emitter(PrepareDeviceScreenEvents.SkipAndContinue) },
modifier = Modifier.padding(vertical = 8.dp)
) {
Text(
text = stringResource(R.string.PrepareDevice_skip_and_continue),
color = MaterialTheme.colorScheme.primary
)
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}
@DayNightPreviews
@Composable
private fun PrepareDeviceScreenPreview() {
Previews.Preview {
PrepareDeviceScreen(
state = QuickTransferOldDeviceState(
reRegisterUri = "",
lastBackupTimestamp = System.currentTimeMillis() - 86400000 // Yesterday
),
emitter = {}
)
}
}
@DayNightPreviews
@Composable
private fun PrepareDeviceScreenNoBackupPreview() {
Previews.Preview {
PrepareDeviceScreen(
state = QuickTransferOldDeviceState(reRegisterUri = ""),
emitter = {}
)
}
}

View File

@@ -0,0 +1,15 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.olddevice.preparedevice
/**
* Events emitted by the PrepareDevice screen.
*/
sealed interface PrepareDeviceScreenEvents {
data object NavigateBack : PrepareDeviceScreenEvents
data object BackUpNow : PrepareDeviceScreenEvents
data object SkipAndContinue : PrepareDeviceScreenEvents
}

View File

@@ -0,0 +1,15 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.olddevice.preparedevice
/**
* State for the PrepareDevice screen shown during quick restore flow.
*
* @param lastBackupTimestamp The timestamp of the last backup in milliseconds, or 0 if never backed up.
*/
data class PrepareDeviceState(
val lastBackupTimestamp: Long = 0
)

View File

@@ -3,15 +3,8 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.olddevice
package org.thoughtcrime.securesms.registration.olddevice.transferaccount
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResultLauncher
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
@@ -31,8 +24,6 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@@ -41,10 +32,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
@@ -52,177 +39,23 @@ import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Texts
import org.signal.core.ui.compose.horizontalGutters
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.BiometricDeviceAuthentication
import org.thoughtcrime.securesms.BiometricDeviceLockContract
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.PassphraseRequiredActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.SignalTheme
import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceTransferActivity
import org.thoughtcrime.securesms.fonts.SignalSymbols
import org.thoughtcrime.securesms.fonts.SignalSymbols.SignalSymbol
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.registration.data.QuickRegistrationRepository
import org.thoughtcrime.securesms.registration.olddevice.QuickTransferOldDeviceActivity
import org.thoughtcrime.securesms.registration.olddevice.QuickTransferOldDeviceState
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
import org.thoughtcrime.securesms.util.DynamicTheme
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.viewModel
import org.whispersystems.signalservice.api.provisioning.RestoreMethod
/**
* Launched after scanning QR code from new device to start the transfer/reregistration process from
* old phone to new phone.
*/
class TransferAccountActivity : PassphraseRequiredActivity() {
companion object {
private val TAG = Log.tag(TransferAccountActivity::class)
private const val KEY_URI = "URI"
const val LEARN_MORE_URL = "https://support.signal.org/hc/articles/360007059752-Backup-and-Restore-Messages"
fun intent(context: Context, uri: String): Intent {
return Intent(context, TransferAccountActivity::class.java).apply {
putExtra(KEY_URI, uri)
}
}
}
private val theme: DynamicTheme = DynamicNoActionBarTheme()
private val viewModel: TransferAccountViewModel by viewModel {
TransferAccountViewModel(intent.getStringExtra(KEY_URI)!!)
}
private lateinit var biometricAuth: BiometricDeviceAuthentication
private lateinit var biometricDeviceLockLauncher: ActivityResultLauncher<String>
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
super.onCreate(savedInstanceState, ready)
theme.onCreate(this)
if (!SignalStore.account.isRegistered) {
finish()
}
biometricDeviceLockLauncher = registerForActivityResult(BiometricDeviceLockContract()) { result: Int ->
if (result == BiometricDeviceAuthentication.AUTHENTICATED) {
Log.i(TAG, "Device authentication succeeded via contract")
transferAccount()
}
}
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setAllowedAuthenticators(BiometricDeviceAuthentication.ALLOWED_AUTHENTICATORS)
.setTitle(getString(R.string.TransferAccount_unlock_to_transfer))
.setConfirmationRequired(true)
.build()
biometricAuth = BiometricDeviceAuthentication(
BiometricManager.from(this),
BiometricPrompt(this, BiometricAuthenticationListener()),
promptInfo
)
lifecycleScope.launch {
val restoreMethodSelected = viewModel
.state
.mapNotNull { it.restoreMethodSelected }
.firstOrNull()
when (restoreMethodSelected) {
RestoreMethod.DEVICE_TRANSFER -> {
startActivities(
arrayOf(
MainActivity.clearTop(this@TransferAccountActivity),
Intent(this@TransferAccountActivity, OldDeviceTransferActivity::class.java)
)
)
}
RestoreMethod.REMOTE_BACKUP,
RestoreMethod.LOCAL_BACKUP,
RestoreMethod.DECLINE,
null -> startActivity(MainActivity.clearTop(this@TransferAccountActivity))
}
}
setContent {
val state by viewModel.state.collectAsState()
SignalTheme {
TransferToNewDevice(
state = state,
onTransferAccount = this::authenticate,
onContinueOnOtherDeviceDismiss = {
finish()
viewModel.clearReRegisterResult()
},
onErrorDismiss = viewModel::clearReRegisterResult,
onBackClicked = { finish() }
)
}
}
}
override fun onPause() {
super.onPause()
biometricAuth.cancelAuthentication()
}
override fun onResume() {
super.onResume()
theme.onResume(this)
}
private fun authenticate() {
val canAuthenticate = biometricAuth.authenticate(this, true) {
biometricDeviceLockLauncher.launch(getString(R.string.TransferAccount_unlock_to_transfer))
}
if (!canAuthenticate) {
Log.w(TAG, "Device authentication not available")
transferAccount()
}
}
private fun transferAccount() {
Log.d(TAG, "transferAccount()")
viewModel.transferAccount()
}
private inner class BiometricAuthenticationListener : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(errorCode: Int, errorString: CharSequence) {
Log.w(TAG, "Device authentication error: $errorCode")
onAuthenticationFailed()
}
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
Log.i(TAG, "Device authentication succeeded")
transferAccount()
}
override fun onAuthenticationFailed() {
Log.w(TAG, "Device authentication failed")
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TransferToNewDevice(
state: TransferAccountViewModel.TransferAccountState,
onTransferAccount: () -> Unit = {},
onContinueOnOtherDeviceDismiss: () -> Unit = {},
onErrorDismiss: () -> Unit = {},
onBackClicked: () -> Unit = {}
fun TransferAccountScreen(
state: QuickTransferOldDeviceState,
emitter: (TransferScreenEvents) -> Unit = {}
) {
Scaffold(
topBar = { TopAppBarContent(onBackClicked = onBackClicked) }
topBar = { TopAppBarContent(onBackClicked = { emitter(TransferScreenEvents.NavigateBack) }) }
) { contentPadding ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
@@ -239,7 +72,7 @@ fun TransferToNewDevice(
val context = LocalContext.current
val learnMore = stringResource(id = R.string.TransferAccount_learn_more)
val fullString = stringResource(id = R.string.TransferAccount_body, learnMore)
val spanned = SpanUtil.urlSubsequence(fullString, learnMore, TransferAccountActivity.LEARN_MORE_URL)
val spanned = SpanUtil.urlSubsequence(fullString, learnMore, QuickTransferOldDeviceActivity.LEARN_MORE_URL)
Texts.LinkifiedText(
textWithUrlSpans = spanned,
onUrlClick = { CommunicationActions.openBrowserLink(context, it) },
@@ -256,7 +89,7 @@ fun TransferToNewDevice(
CircularProgressIndicator()
} else {
Buttons.LargeTonal(
onClick = onTransferAccount,
onClick = { emitter(TransferScreenEvents.TransferClicked) },
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(id = R.string.TransferAccount_button))
@@ -282,7 +115,7 @@ fun TransferToNewDevice(
QuickRegistrationRepository.TransferAccountResult.SUCCESS -> {
ModalBottomSheet(
dragHandle = null,
onDismissRequest = onContinueOnOtherDeviceDismiss,
onDismissRequest = { emitter(TransferScreenEvents.ContinueOnOtherDeviceDismiss) },
sheetState = sheetState
) {
ContinueOnOtherDevice()
@@ -293,7 +126,7 @@ fun TransferToNewDevice(
Dialogs.SimpleMessageDialog(
message = stringResource(R.string.RegistrationActivity_unable_to_connect_to_service),
dismiss = stringResource(android.R.string.ok),
onDismiss = onErrorDismiss
onDismiss = { emitter(TransferScreenEvents.ErrorDialogDismissed) }
)
}
@@ -304,9 +137,9 @@ fun TransferToNewDevice(
@DayNightPreviews
@Composable
private fun TransferToNewDevicePreview() {
private fun TransferAccountScreenPreview() {
Previews.Preview {
TransferToNewDevice(state = TransferAccountViewModel.TransferAccountState("sgnl://rereg"))
TransferAccountScreen(state = QuickTransferOldDeviceState("sgnl://rereg"))
}
}

View File

@@ -0,0 +1,13 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.olddevice.transferaccount
sealed interface TransferScreenEvents {
data object TransferClicked : TransferScreenEvents
data object ContinueOnOtherDeviceDismiss : TransferScreenEvents
data object ErrorDialogDismissed : TransferScreenEvents
data object NavigateBack : TransferScreenEvents
}