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 + + + + + + + +