diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index cc3e1615b1..3004c50ecb 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -540,6 +540,8 @@ dependencies {
implementation(libs.androidx.navigation.fragment.ktx)
implementation(libs.androidx.navigation.ui.ktx)
implementation(libs.androidx.navigation.compose)
+ implementation(libs.androidx.navigation3.runtime)
+ implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.lifecycle.livedata.ktx)
implementation(libs.androidx.lifecycle.process)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 1d4c6422f3..9a2678b5f8 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -588,7 +588,7 @@
android:noHistory="true" />
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsActivity.kt
index afc11566bd..2172b4040f 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsActivity.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsActivity.kt
@@ -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)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsFragment.kt
index 5a2521baf9..33f7f7b595 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsFragment.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsFragment.kt
@@ -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() {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsState.kt
index 33807bf7a5..f197377786 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsState.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsState.kt
@@ -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 {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsViewModel.kt
index 40034a8155..c748b632aa 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsViewModel.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsViewModel.kt
@@ -88,6 +88,8 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
val state: StateFlow = _state
val restoreState: StateFlow = _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() {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/routes/AppSettingsRoute.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/routes/AppSettingsRoute.kt
index 647d5ca9de..05de684db9 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/routes/AppSettingsRoute.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/routes/AppSettingsRoute.kt
@@ -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
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureFragment.kt
index 2e06bbd34b..98552860e8 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureFragment.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureFragment.kt
@@ -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()
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/olddevice/QuickTransferOldDeviceActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/olddevice/QuickTransferOldDeviceActivity.kt
new file mode 100644
index 0000000000..b3c0a79c9d
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registration/olddevice/QuickTransferOldDeviceActivity.kt
@@ -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
+
+ 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")
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/olddevice/QuickTransferOldDeviceNavigation.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/olddevice/QuickTransferOldDeviceNavigation.kt
new file mode 100644
index 0000000000..439968f8f1
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registration/olddevice/QuickTransferOldDeviceNavigation.kt
@@ -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()
+ )
+
+ 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.navigationEntries(
+ viewModel: QuickTransferOldDeviceViewModel,
+ onFinished: () -> Unit
+) {
+ entry {
+ val state by viewModel.state.collectAsStateWithLifecycle()
+
+ TransferAccountScreen(
+ state = state,
+ emitter = { viewModel.onEvent(it) }
+ )
+ }
+ entry {
+ val state by viewModel.state.collectAsStateWithLifecycle()
+
+ PrepareDeviceScreen(
+ state = state,
+ emitter = { viewModel.onEvent(it) }
+ )
+ }
+ entry {
+ LaunchedEffect(Unit) {
+ onFinished()
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/olddevice/QuickTransferOldDeviceState.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/olddevice/QuickTransferOldDeviceState.kt
new file mode 100644
index 0000000000..371782e9e4
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registration/olddevice/QuickTransferOldDeviceState.kt
@@ -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
+)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/olddevice/QuickTransferOldDeviceViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/olddevice/QuickTransferOldDeviceViewModel.kt
new file mode 100644
index 0000000000..add086a5d3
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registration/olddevice/QuickTransferOldDeviceViewModel.kt
@@ -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 = MutableStateFlow(
+ QuickTransferOldDeviceState(
+ reRegisterUri = reRegisterUri,
+ lastBackupTimestamp = SignalStore.backup.lastBackupTime
+ )
+ )
+
+ val state: StateFlow = store
+
+ private val _backStack: MutableStateFlow> = MutableStateFlow(listOf(TransferAccountRoute.Transfer))
+ val backStack: StateFlow> = _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) }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/olddevice/TransferAccountViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/olddevice/TransferAccountViewModel.kt
deleted file mode 100644
index 79e699a3bf..0000000000
--- a/app/src/main/java/org/thoughtcrime/securesms/registration/olddevice/TransferAccountViewModel.kt
+++ /dev/null
@@ -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 = MutableStateFlow(TransferAccountState(reRegisterUri))
-
- val state: StateFlow = 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
- )
-}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/olddevice/preparedevice/PrepareDeviceScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/olddevice/preparedevice/PrepareDeviceScreen.kt
new file mode 100644
index 0000000000..c3065befa2
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registration/olddevice/preparedevice/PrepareDeviceScreen.kt
@@ -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 = {}
+ )
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/olddevice/preparedevice/PrepareDeviceScreenEvents.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/olddevice/preparedevice/PrepareDeviceScreenEvents.kt
new file mode 100644
index 0000000000..879ce52e17
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registration/olddevice/preparedevice/PrepareDeviceScreenEvents.kt
@@ -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
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/olddevice/preparedevice/PrepareDeviceState.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/olddevice/preparedevice/PrepareDeviceState.kt
new file mode 100644
index 0000000000..071bc06af2
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registration/olddevice/preparedevice/PrepareDeviceState.kt
@@ -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
+)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/olddevice/TransferAccountActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/olddevice/transferaccount/TransferScreen.kt
similarity index 51%
rename from app/src/main/java/org/thoughtcrime/securesms/registration/olddevice/TransferAccountActivity.kt
rename to app/src/main/java/org/thoughtcrime/securesms/registration/olddevice/transferaccount/TransferScreen.kt
index d9e42c334c..adac15a27d 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/registration/olddevice/TransferAccountActivity.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/registration/olddevice/transferaccount/TransferScreen.kt
@@ -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
-
- 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"))
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/olddevice/transferaccount/TransferScreenEvents.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/olddevice/transferaccount/TransferScreenEvents.kt
new file mode 100644
index 0000000000..a6f95465a0
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registration/olddevice/transferaccount/TransferScreenEvents.kt
@@ -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
+}
diff --git a/app/src/main/res/drawable/illustration_prepare_backup.xml b/app/src/main/res/drawable/illustration_prepare_backup.xml
new file mode 100644
index 0000000000..6cf867808b
--- /dev/null
+++ b/app/src/main/res/drawable/illustration_prepare_backup.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/illustration_scan_qr_transfer.xml b/app/src/main/res/drawable/illustration_scan_qr_transfer.xml
new file mode 100644
index 0000000000..bf57876cae
--- /dev/null
+++ b/app/src/main/res/drawable/illustration_scan_qr_transfer.xml
@@ -0,0 +1,110 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/navigation/app_settings_with_change_number.xml b/app/src/main/res/navigation/app_settings_with_change_number.xml
index 50dcac816f..70a9107c97 100644
--- a/app/src/main/res/navigation/app_settings_with_change_number.xml
+++ b/app/src/main/res/navigation/app_settings_with_change_number.xml
@@ -733,7 +733,12 @@
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit"
app:popUpTo="@id/app_settings"
- app:popUpToInclusive="true" />
+ app:popUpToInclusive="true">
+
+
+
+
Manage storage
+
+
+ Ready to transfer
+
+ Use this device to scan the QR code on the device you want to transfer to
+
+ Scan QR code
+
Download your backup today
@@ -8831,6 +8839,19 @@
Continue transferring your account on your other device.
+
+ Getting your device ready
+
+ Back up now before transferring. You may have received messages that haven\'t been backed up yet.
+
+ Your last backup was made on %1$s at %2$s.
+
+ Back up now
+
+ Skip and continue
+
+ Your last backup was made %1$s.
+
Finish on your other device
diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts
index 2536063cb9..54dc41a4c2 100644
--- a/core/ui/build.gradle.kts
+++ b/core/ui/build.gradle.kts
@@ -28,6 +28,8 @@ dependencies {
api(libs.androidx.compose.material3.adaptive)
api(libs.androidx.compose.material3.adaptive.layout)
api(libs.androidx.compose.material3.adaptive.navigation)
+ implementation(libs.androidx.navigation3.ui)
+ implementation(libs.androidx.navigation3.runtime)
api(libs.androidx.compose.ui.tooling.preview)
api(libs.androidx.activity.compose)
debugApi(libs.androidx.compose.ui.tooling.core)
diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/Scaffolds.kt b/core/ui/src/main/java/org/signal/core/ui/compose/Scaffolds.kt
index 6b043694a9..ad8b7d454b 100644
--- a/core/ui/src/main/java/org/signal/core/ui/compose/Scaffolds.kt
+++ b/core/ui/src/main/java/org/signal/core/ui/compose/Scaffolds.kt
@@ -5,6 +5,7 @@
package org.signal.core.ui.compose
+import androidx.annotation.DrawableRes
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope
@@ -32,6 +33,49 @@ import org.signal.core.ui.compose.theme.SignalTheme
@OptIn(ExperimentalMaterial3Api::class)
object Scaffolds {
+ /**
+ * Simple scaffold with a TopAppBar containing a navigation icon and optional title.
+ *
+ * @param onNavigationClick Callback when navigation icon is clicked.
+ * @param navigationIconRes Drawable resource for the navigation icon.
+ * @param navigationContentDescription Content description for the navigation icon.
+ * @param title Optional title text for the app bar.
+ * @param modifier Modifier for the scaffold.
+ * @param content Content to display in the scaffold.
+ */
+ @Composable
+ fun Default(
+ onNavigationClick: () -> Unit,
+ @DrawableRes navigationIconRes: Int,
+ navigationContentDescription: String? = null,
+ title: String? = null,
+ modifier: Modifier = Modifier,
+ content: @Composable (PaddingValues) -> Unit
+ ) {
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = {
+ if (title != null) {
+ Text(text = title)
+ }
+ },
+ navigationIcon = {
+ IconButton(onClick = onNavigationClick) {
+ Icon(
+ painter = painterResource(navigationIconRes),
+ tint = MaterialTheme.colorScheme.onSurface,
+ contentDescription = navigationContentDescription
+ )
+ }
+ }
+ )
+ },
+ content = content
+ )
+ }
+
/**
* Settings scaffold that takes an icon as an ImageVector.
*
diff --git a/core/ui/src/main/java/org/signal/core/ui/navigation/TransitionSpecs.kt b/core/ui/src/main/java/org/signal/core/ui/navigation/TransitionSpecs.kt
new file mode 100644
index 0000000000..99553f9dc4
--- /dev/null
+++ b/core/ui/src/main/java/org/signal/core/ui/navigation/TransitionSpecs.kt
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.core.ui.navigation
+
+import androidx.compose.animation.AnimatedContentTransitionScope
+import androidx.compose.animation.ContentTransform
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.animation.slideOutHorizontally
+import androidx.compose.animation.togetherWith
+import androidx.navigation3.runtime.NavKey
+import androidx.navigation3.scene.Scene
+import androidx.navigationevent.NavigationEvent
+
+/**
+ * A collection of [TransitionSpecs] for setting up nav3 navigation.
+ */
+object TransitionSpecs {
+
+ /**
+ * Screens slide in from the right and slide out from the left.
+ */
+ object HorizontalSlide {
+ val transitionSpec: AnimatedContentTransitionScope>.() -> ContentTransform = {
+ (
+ slideInHorizontally(
+ initialOffsetX = { it },
+ animationSpec = tween(200)
+ ) + fadeIn(animationSpec = tween(200))
+ ) togetherWith
+ (
+ slideOutHorizontally(
+ targetOffsetX = { -it },
+ animationSpec = tween(200)
+ ) + fadeOut(animationSpec = tween(200))
+ )
+ }
+
+ val popTransitionSpec: AnimatedContentTransitionScope>.() -> ContentTransform = {
+ (
+ slideInHorizontally(
+ initialOffsetX = { -it },
+ animationSpec = tween(200)
+ ) + fadeIn(animationSpec = tween(200))
+ ) togetherWith
+ (
+ slideOutHorizontally(
+ targetOffsetX = { it },
+ animationSpec = tween(200)
+ ) + fadeOut(animationSpec = tween(200))
+ )
+ }
+
+ val predictivePopTransitonSpec: AnimatedContentTransitionScope>.(@NavigationEvent.SwipeEdge Int) -> ContentTransform = {
+ (
+ slideInHorizontally(
+ initialOffsetX = { -it },
+ animationSpec = tween(200)
+ ) + fadeIn(animationSpec = tween(200))
+ ) togetherWith
+ (
+ slideOutHorizontally(
+ targetOffsetX = { it },
+ animationSpec = tween(200)
+ ) + fadeOut(animationSpec = tween(200))
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml
index 215c1b6f02..133fd1f998 100644
--- a/gradle/verification-metadata.xml
+++ b/gradle/verification-metadata.xml
@@ -6522,6 +6522,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
+
+
+
+
+
+
+
+