Prompt users to backup during quick restore.

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

View File

@@ -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)

View File

@@ -588,7 +588,7 @@
android:noHistory="true" />
<activity
android:name=".registration.olddevice.TransferAccountActivity"
android:name=".registration.olddevice.QuickTransferOldDeviceActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"
android:theme="@style/Theme.Signal.DayNight.NoActionBar" />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,24 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="112dp"
android:height="164dp"
android:viewportWidth="112"
android:viewportHeight="164">
<path
android:pathData="M20,25.74C20,20.1 25,15.52 31.16,15.52H80.25C86.41,15.52 91.41,20.1 91.41,25.74V140.7C91.41,146.34 86.41,150.91 80.25,150.91H31.16C25,150.91 20,146.34 20,140.7V25.74Z"
android:fillColor="#EDF0F6"/>
<path
android:pathData="M92.93,74.85V139.66C92.93,145.49 87.83,150.21 81.54,150.21H31.39C25.1,150.21 20,145.49 20,139.66V115.96C26.49,105.58 35.44,96.63 46.13,89.76C60.24,80.67 74.39,75.21 92.93,74.85Z"
android:fillColor="#E1E8F7"
android:fillType="evenOdd"/>
<path
android:pathData="M80.78,14H32.15C25.44,14 20,19.45 20,26.17V138.74C20,145.47 25.44,150.91 32.15,150.91H80.78C87.49,150.91 92.93,145.47 92.93,138.74V26.17C92.93,19.45 87.49,14 80.78,14Z"
android:strokeWidth="3"
android:fillColor="#00000000"
android:strokeColor="#858FA8"/>
<path
android:pathData="M58,67.96C49.72,67.96 43,74.67 43,82.96C43,86.72 44.39,90.17 46.68,92.8L47.32,92.17C48.12,91.37 49.48,91.74 49.76,92.83L51.03,97.78C51.3,98.85 50.33,99.83 49.25,99.55L44.31,98.29C43.21,98.01 42.84,96.64 43.64,95.85L44.32,95.16C41.43,91.92 39.67,87.64 39.67,82.96C39.67,72.83 47.87,64.62 58,64.62C68.13,64.62 76.33,72.83 76.33,82.96C76.33,93.08 68.13,101.29 58,101.29C57.08,101.29 56.33,100.54 56.33,99.62C56.33,98.7 57.08,97.96 58,97.96C66.28,97.96 73,91.24 73,82.96C73,74.67 66.28,67.96 58,67.96Z"
android:fillColor="#6F7B99"/>
<path
android:pathData="M55.7,71.67C55.72,70.88 56.37,70.25 57.17,70.25C57.96,70.25 58.61,70.88 58.64,71.67L58.98,82L65.96,82.31C66.75,82.35 67.38,83 67.38,83.79C67.38,84.58 66.75,85.23 65.96,85.27L57.31,85.66C57.26,85.66 57.21,85.66 57.17,85.66C56.13,85.66 55.29,84.83 55.29,83.79C55.29,83.75 55.29,83.72 55.29,83.68L55.7,71.67Z"
android:fillColor="#6F7B99"/>
</vector>

View File

@@ -0,0 +1,110 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="192dp"
android:height="192dp"
android:viewportWidth="192"
android:viewportHeight="192">
<path
android:pathData="M74.75,89C74.75,88.31 75.31,87.75 76,87.75H77.67C78.36,87.75 78.92,88.31 78.92,89V90.67C78.92,91.36 78.36,91.92 77.67,91.92H76C75.31,91.92 74.75,91.36 74.75,90.67V89Z"
android:fillColor="#020BAC"/>
<path
android:pathData="M74.53,82.13H79.14C79.79,82.13 80.37,82.12 80.84,82.16C81.35,82.21 81.87,82.3 82.38,82.56C83.13,82.94 83.73,83.54 84.11,84.29C84.37,84.79 84.46,85.32 84.5,85.82C84.54,86.3 84.54,86.88 84.54,87.53V92.14C84.54,92.79 84.54,93.37 84.5,93.84C84.46,94.35 84.37,94.87 84.11,95.38C83.73,96.13 83.13,96.73 82.38,97.11C81.87,97.37 81.35,97.46 80.84,97.5C80.37,97.54 79.79,97.54 79.14,97.54H74.53C73.88,97.54 73.3,97.54 72.82,97.5C72.32,97.46 71.79,97.37 71.29,97.11C70.54,96.73 69.94,96.13 69.56,95.38C69.3,94.87 69.21,94.35 69.16,93.84C69.12,93.37 69.13,92.79 69.13,92.14V87.53C69.13,86.88 69.12,86.3 69.16,85.82C69.21,85.32 69.3,84.79 69.56,84.29C69.94,83.54 70.54,82.94 71.29,82.56C71.79,82.3 72.32,82.21 72.82,82.16C73.3,82.12 73.88,82.13 74.53,82.13ZM73.06,85.07C72.73,85.1 72.64,85.14 72.61,85.16C72.41,85.26 72.26,85.41 72.16,85.61C72.14,85.64 72.1,85.73 72.07,86.06C72.04,86.4 72.04,86.86 72.04,87.58V92.08C72.04,92.81 72.04,93.26 72.07,93.61C72.1,93.93 72.14,94.03 72.16,94.06C72.26,94.25 72.41,94.41 72.61,94.51C72.64,94.53 72.73,94.57 73.06,94.6C73.4,94.62 73.86,94.63 74.58,94.63H79.08C79.81,94.63 80.26,94.62 80.61,94.6C80.93,94.57 81.03,94.53 81.06,94.51C81.25,94.41 81.41,94.25 81.51,94.06C81.53,94.03 81.57,93.93 81.6,93.61C81.62,93.26 81.63,92.81 81.63,92.08V87.58C81.63,86.86 81.62,86.4 81.6,86.06C81.57,85.73 81.53,85.64 81.51,85.61C81.41,85.41 81.25,85.26 81.06,85.16C81.03,85.14 80.93,85.1 80.61,85.07C80.26,85.04 79.81,85.04 79.08,85.04H74.58C73.86,85.04 73.4,85.04 73.06,85.07Z"
android:fillColor="#020BAC"
android:fillType="evenOdd"/>
<path
android:pathData="M76,106.08C75.31,106.08 74.75,106.64 74.75,107.33V109C74.75,109.69 75.31,110.25 76,110.25H77.67C78.36,110.25 78.92,109.69 78.92,109V107.33C78.92,106.64 78.36,106.08 77.67,106.08H76Z"
android:fillColor="#020BAC"/>
<path
android:pathData="M74.53,100.46H79.14C79.79,100.46 80.37,100.46 80.84,100.5C81.35,100.54 81.87,100.63 82.38,100.89C83.13,101.27 83.73,101.88 84.11,102.62C84.37,103.13 84.46,103.65 84.5,104.16C84.54,104.63 84.54,105.21 84.54,105.86V110.47C84.54,111.13 84.54,111.7 84.5,112.18C84.46,112.68 84.37,113.21 84.11,113.71C83.73,114.46 83.13,115.06 82.38,115.44C81.87,115.7 81.35,115.79 80.84,115.84C80.37,115.88 79.79,115.88 79.14,115.88H74.53C73.88,115.88 73.3,115.88 72.82,115.84C72.32,115.79 71.79,115.7 71.29,115.44C70.54,115.06 69.94,114.46 69.56,113.71C69.3,113.21 69.21,112.68 69.16,112.18C69.12,111.7 69.13,111.13 69.13,110.47V105.86C69.13,105.21 69.12,104.63 69.16,104.16C69.21,103.65 69.3,103.13 69.56,102.62C69.94,101.88 70.54,101.27 71.29,100.89C71.79,100.63 72.32,100.54 72.82,100.5C73.3,100.46 73.88,100.46 74.53,100.46ZM73.06,103.4C72.73,103.43 72.64,103.47 72.61,103.49C72.41,103.59 72.26,103.75 72.16,103.94C72.14,103.97 72.1,104.07 72.07,104.39C72.04,104.74 72.04,105.19 72.04,105.92V110.42C72.04,111.14 72.04,111.6 72.07,111.94C72.1,112.27 72.14,112.36 72.16,112.39C72.26,112.59 72.41,112.75 72.61,112.85C72.64,112.86 72.73,112.9 73.06,112.93C73.4,112.96 73.86,112.96 74.58,112.96H79.08C79.81,112.96 80.26,112.96 80.61,112.93C80.93,112.9 81.03,112.86 81.06,112.85C81.25,112.75 81.41,112.59 81.51,112.39C81.53,112.36 81.57,112.27 81.6,111.94C81.62,111.6 81.63,111.14 81.63,110.42V105.92C81.63,105.19 81.62,104.74 81.6,104.39C81.57,104.07 81.53,103.97 81.51,103.94C81.41,103.75 81.25,103.59 81.06,103.49C81.03,103.47 80.93,103.43 80.61,103.4C80.26,103.38 79.81,103.38 79.08,103.38H74.58C73.86,103.38 73.4,103.38 73.06,103.4Z"
android:fillColor="#020BAC"
android:fillType="evenOdd"/>
<path
android:pathData="M94.33,87.75C93.64,87.75 93.08,88.31 93.08,89V90.67C93.08,91.36 93.64,91.92 94.33,91.92H96C96.69,91.92 97.25,91.36 97.25,90.67V89C97.25,88.31 96.69,87.75 96,87.75H94.33Z"
android:fillColor="#020BAC"/>
<path
android:pathData="M92.86,82.13C92.21,82.13 91.63,82.12 91.16,82.16C90.65,82.21 90.13,82.3 89.62,82.56C88.87,82.94 88.27,83.54 87.89,84.29C87.63,84.79 87.54,85.32 87.5,85.82C87.46,86.3 87.46,86.88 87.46,87.53V92.14C87.46,92.79 87.46,93.37 87.5,93.84C87.54,94.35 87.63,94.87 87.89,95.38C88.27,96.13 88.87,96.73 89.62,97.11C90.13,97.37 90.65,97.46 91.16,97.5C91.63,97.54 92.21,97.54 92.86,97.54H97.47C98.12,97.54 98.7,97.54 99.18,97.5C99.68,97.46 100.21,97.37 100.71,97.11C101.46,96.73 102.06,96.13 102.44,95.38C102.7,94.87 102.79,94.35 102.84,93.84C102.88,93.37 102.88,92.79 102.88,92.14V87.53C102.88,86.88 102.88,86.3 102.84,85.82C102.79,85.32 102.7,84.79 102.44,84.29C102.06,83.54 101.46,82.94 100.71,82.56C100.21,82.3 99.68,82.21 99.18,82.16C98.7,82.12 98.12,82.13 97.47,82.13H92.86ZM90.94,85.16C90.97,85.14 91.07,85.1 91.39,85.07C91.74,85.04 92.19,85.04 92.92,85.04H97.42C98.14,85.04 98.6,85.04 98.94,85.07C99.27,85.1 99.36,85.14 99.39,85.16C99.59,85.26 99.74,85.41 99.84,85.61C99.86,85.64 99.9,85.73 99.93,86.06C99.96,86.4 99.96,86.86 99.96,87.58V92.08C99.96,92.81 99.96,93.26 99.93,93.61C99.9,93.93 99.86,94.03 99.84,94.06C99.74,94.25 99.59,94.41 99.39,94.51C99.36,94.53 99.27,94.57 98.94,94.6C98.6,94.62 98.14,94.63 97.42,94.63H92.92C92.19,94.63 91.74,94.62 91.39,94.6C91.07,94.57 90.97,94.53 90.94,94.51C90.75,94.41 90.59,94.25 90.49,94.06C90.47,94.03 90.43,93.93 90.4,93.61C90.38,93.26 90.38,92.81 90.38,92.08V87.58C90.38,86.86 90.38,86.4 90.4,86.06C90.43,85.73 90.47,85.64 90.49,85.61C90.59,85.41 90.75,85.26 90.94,85.16Z"
android:fillColor="#020BAC"
android:fillType="evenOdd"/>
<path
android:pathData="M89.75,101.5C89.06,101.5 88.5,102.06 88.5,102.75V104.42C88.5,105.11 89.06,105.67 89.75,105.67H91.42C92.11,105.67 92.67,105.11 92.67,104.42V102.75C92.67,102.06 92.11,101.5 91.42,101.5H89.75Z"
android:fillColor="#020BAC"/>
<path
android:pathData="M93.08,107.33C93.08,106.64 93.64,106.08 94.33,106.08H96C96.69,106.08 97.25,106.64 97.25,107.33V109C97.25,109.69 96.69,110.25 96,110.25H94.33C93.64,110.25 93.08,109.69 93.08,109V107.33Z"
android:fillColor="#020BAC"/>
<path
android:pathData="M97.67,111.92C97.67,111.23 98.23,110.67 98.92,110.67H100.58C101.27,110.67 101.83,111.23 101.83,111.92V113.58C101.83,114.27 101.27,114.83 100.58,114.83H98.92C98.23,114.83 97.67,114.27 97.67,113.58V111.92Z"
android:fillColor="#020BAC"/>
<path
android:pathData="M98.92,101.5C98.23,101.5 97.67,102.06 97.67,102.75V104.42C97.67,105.11 98.23,105.67 98.92,105.67H100.58C101.27,105.67 101.83,105.11 101.83,104.42V102.75C101.83,102.06 101.27,101.5 100.58,101.5H98.92Z"
android:fillColor="#020BAC"/>
<path
android:pathData="M88.5,111.92C88.5,111.23 89.06,110.67 89.75,110.67H91.42C92.11,110.67 92.67,111.23 92.67,111.92V113.58C92.67,114.27 92.11,114.83 91.42,114.83H89.75C89.06,114.83 88.5,114.27 88.5,113.58V111.92Z"
android:fillColor="#020BAC"/>
<path
android:pathData="M64,24L120,24A12,12 0,0 1,132 36L132,160A12,12 0,0 1,120 172L64,172A12,12 0,0 1,52 160L52,36A12,12 0,0 1,64 24z"
android:fillColor="#E3E8FE"/>
<path
android:pathData="M64,24L120,24A12,12 0,0 1,132 36L132,160A12,12 0,0 1,120 172L64,172A12,12 0,0 1,52 160L52,36A12,12 0,0 1,64 24z"
android:strokeWidth="3"
android:fillColor="#00000000"
android:strokeColor="#020BAC"/>
<path
android:pathData="M78.5,87C78.5,86.17 79.17,85.5 80,85.5H82C82.83,85.5 83.5,86.17 83.5,87V89C83.5,89.83 82.83,90.5 82,90.5H80C79.17,90.5 78.5,89.83 78.5,89V87Z"
android:fillColor="#020BAC"/>
<path
android:pathData="M78.23,78.75H83.77C84.55,78.75 85.24,78.75 85.81,78.8C86.42,78.85 87.05,78.96 87.66,79.27C88.55,79.72 89.28,80.45 89.73,81.34C90.04,81.95 90.15,82.58 90.2,83.19C90.25,83.76 90.25,84.45 90.25,85.23V90.77C90.25,91.55 90.25,92.24 90.2,92.81C90.15,93.42 90.04,94.05 89.73,94.66C89.28,95.55 88.55,96.28 87.66,96.73C87.05,97.04 86.42,97.15 85.81,97.2C85.24,97.25 84.55,97.25 83.77,97.25H78.23C77.45,97.25 76.76,97.25 76.19,97.2C75.58,97.15 74.95,97.04 74.34,96.73C73.45,96.28 72.72,95.55 72.27,94.66C71.96,94.05 71.85,93.42 71.8,92.81C71.75,92.24 71.75,91.55 71.75,90.77V85.23C71.75,84.45 71.75,83.76 71.8,83.19C71.85,82.58 71.96,81.95 72.27,81.34C72.72,80.45 73.45,79.72 74.34,79.27C74.95,78.96 75.58,78.85 76.19,78.8C76.76,78.75 77.45,78.75 78.23,78.75ZM76.47,82.29C76.08,82.32 75.97,82.37 75.93,82.39C75.7,82.51 75.51,82.7 75.39,82.93C75.37,82.97 75.32,83.08 75.29,83.47C75.25,83.88 75.25,84.43 75.25,85.3V90.7C75.25,91.57 75.25,92.12 75.29,92.53C75.32,92.92 75.37,93.03 75.39,93.07C75.51,93.3 75.7,93.49 75.93,93.61C75.97,93.63 76.08,93.68 76.47,93.71C76.88,93.75 77.43,93.75 78.3,93.75H83.7C84.57,93.75 85.12,93.75 85.53,93.71C85.92,93.68 86.03,93.63 86.07,93.61C86.3,93.49 86.49,93.3 86.61,93.07C86.63,93.03 86.68,92.92 86.71,92.53C86.75,92.12 86.75,91.57 86.75,90.7V85.3C86.75,84.43 86.75,83.88 86.71,83.47C86.68,83.08 86.63,82.97 86.61,82.93C86.49,82.7 86.3,82.51 86.07,82.39C86.03,82.37 85.92,82.32 85.53,82.29C85.12,82.25 84.57,82.25 83.7,82.25H78.3C77.43,82.25 76.88,82.25 76.47,82.29Z"
android:fillColor="#020BAC"
android:fillType="evenOdd"/>
<path
android:pathData="M80,107.5C79.17,107.5 78.5,108.17 78.5,109V111C78.5,111.83 79.17,112.5 80,112.5H82C82.83,112.5 83.5,111.83 83.5,111V109C83.5,108.17 82.83,107.5 82,107.5H80Z"
android:fillColor="#020BAC"/>
<path
android:pathData="M78.23,100.75H83.77C84.55,100.75 85.24,100.75 85.81,100.8C86.42,100.85 87.05,100.96 87.66,101.27C88.55,101.72 89.28,102.45 89.73,103.34C90.04,103.95 90.15,104.58 90.2,105.19C90.25,105.76 90.25,106.45 90.25,107.24V112.76C90.25,113.55 90.25,114.24 90.2,114.81C90.15,115.42 90.04,116.05 89.73,116.66C89.28,117.55 88.55,118.28 87.66,118.73C87.05,119.04 86.42,119.15 85.81,119.2C85.24,119.25 84.55,119.25 83.77,119.25H78.23C77.45,119.25 76.76,119.25 76.19,119.2C75.58,119.15 74.95,119.04 74.34,118.73C73.45,118.28 72.72,117.55 72.27,116.66C71.96,116.05 71.85,115.42 71.8,114.81C71.75,114.24 71.75,113.55 71.75,112.76V107.24C71.75,106.45 71.75,105.76 71.8,105.19C71.85,104.58 71.96,103.95 72.27,103.34C72.72,102.45 73.45,101.72 74.34,101.27C74.95,100.96 75.58,100.85 76.19,100.8C76.76,100.75 77.45,100.75 78.23,100.75ZM76.47,104.29C76.08,104.32 75.97,104.37 75.93,104.39C75.7,104.51 75.51,104.7 75.39,104.93C75.37,104.97 75.32,105.08 75.29,105.47C75.25,105.89 75.25,106.43 75.25,107.3V112.7C75.25,113.57 75.25,114.11 75.29,114.53C75.32,114.92 75.37,115.03 75.39,115.07C75.51,115.3 75.7,115.49 75.93,115.61C75.97,115.63 76.08,115.68 76.47,115.71C76.88,115.75 77.43,115.75 78.3,115.75H83.7C84.57,115.75 85.12,115.75 85.53,115.71C85.92,115.68 86.03,115.63 86.07,115.61C86.3,115.49 86.49,115.3 86.61,115.07C86.63,115.03 86.68,114.92 86.71,114.53C86.75,114.11 86.75,113.57 86.75,112.7V107.3C86.75,106.43 86.75,105.89 86.71,105.47C86.68,105.08 86.63,104.97 86.61,104.93C86.49,104.7 86.3,104.51 86.07,104.39C86.03,104.37 85.92,104.32 85.53,104.29C85.12,104.25 84.57,104.25 83.7,104.25H78.3C77.43,104.25 76.88,104.25 76.47,104.29Z"
android:fillColor="#020BAC"
android:fillType="evenOdd"/>
<path
android:pathData="M102,85.5C101.17,85.5 100.5,86.17 100.5,87V89C100.5,89.83 101.17,90.5 102,90.5H104C104.83,90.5 105.5,89.83 105.5,89V87C105.5,86.17 104.83,85.5 104,85.5H102Z"
android:fillColor="#020BAC"/>
<path
android:pathData="M100.24,78.75C99.45,78.75 98.76,78.75 98.19,78.8C97.58,78.85 96.95,78.96 96.34,79.27C95.45,79.72 94.72,80.45 94.27,81.34C93.96,81.95 93.85,82.58 93.8,83.19C93.75,83.76 93.75,84.45 93.75,85.23V90.77C93.75,91.55 93.75,92.24 93.8,92.81C93.85,93.42 93.96,94.05 94.27,94.66C94.72,95.55 95.45,96.28 96.34,96.73C96.95,97.04 97.58,97.15 98.19,97.2C98.76,97.25 99.45,97.25 100.24,97.25H105.76C106.55,97.25 107.24,97.25 107.81,97.2C108.42,97.15 109.05,97.04 109.66,96.73C110.55,96.28 111.28,95.55 111.73,94.66C112.04,94.05 112.15,93.42 112.2,92.81C112.25,92.24 112.25,91.55 112.25,90.77V85.23C112.25,84.45 112.25,83.76 112.2,83.19C112.15,82.58 112.04,81.95 111.73,81.34C111.28,80.45 110.55,79.72 109.66,79.27C109.05,78.96 108.42,78.85 107.81,78.8C107.24,78.75 106.55,78.75 105.76,78.75H100.24ZM97.93,82.39C97.97,82.37 98.08,82.32 98.47,82.29C98.88,82.25 99.43,82.25 100.3,82.25H105.7C106.57,82.25 107.11,82.25 107.53,82.29C107.92,82.32 108.03,82.37 108.07,82.39C108.3,82.51 108.49,82.7 108.61,82.93C108.63,82.97 108.68,83.08 108.71,83.47C108.75,83.88 108.75,84.43 108.75,85.3V90.7C108.75,91.57 108.75,92.12 108.71,92.53C108.68,92.92 108.63,93.03 108.61,93.07C108.49,93.3 108.3,93.49 108.07,93.61C108.03,93.63 107.92,93.68 107.53,93.71C107.11,93.75 106.57,93.75 105.7,93.75H100.3C99.43,93.75 98.88,93.75 98.47,93.71C98.08,93.68 97.97,93.63 97.93,93.61C97.7,93.49 97.51,93.3 97.39,93.07C97.37,93.03 97.32,92.92 97.29,92.53C97.25,92.12 97.25,91.57 97.25,90.7V85.3C97.25,84.43 97.25,83.88 97.29,83.47C97.32,83.08 97.37,82.97 97.39,82.93C97.51,82.7 97.7,82.51 97.93,82.39Z"
android:fillColor="#020BAC"
android:fillType="evenOdd"/>
<path
android:pathData="M96.5,102C95.67,102 95,102.67 95,103.5V105.5C95,106.33 95.67,107 96.5,107H98.5C99.33,107 100,106.33 100,105.5V103.5C100,102.67 99.33,102 98.5,102H96.5Z"
android:fillColor="#020BAC"/>
<path
android:pathData="M100.5,109C100.5,108.17 101.17,107.5 102,107.5H104C104.83,107.5 105.5,108.17 105.5,109V111C105.5,111.83 104.83,112.5 104,112.5H102C101.17,112.5 100.5,111.83 100.5,111V109Z"
android:fillColor="#020BAC"/>
<path
android:pathData="M106,114.5C106,113.67 106.67,113 107.5,113H109.5C110.33,113 111,113.67 111,114.5V116.5C111,117.33 110.33,118 109.5,118H107.5C106.67,118 106,117.33 106,116.5V114.5Z"
android:fillColor="#020BAC"/>
<path
android:pathData="M107.5,102C106.67,102 106,102.67 106,103.5V105.5C106,106.33 106.67,107 107.5,107H109.5C110.33,107 111,106.33 111,105.5V103.5C111,102.67 110.33,102 109.5,102H107.5Z"
android:fillColor="#020BAC"/>
<path
android:pathData="M95,114.5C95,113.67 95.67,113 96.5,113H98.5C99.33,113 100,113.67 100,114.5V116.5C100,117.33 99.33,118 98.5,118H96.5C95.67,118 95,117.33 95,116.5V114.5Z"
android:fillColor="#020BAC"/>
<path
android:pathData="M76,69H66C63.79,69 62,70.79 62,73V82"
android:strokeWidth="3"
android:fillColor="#00000000"
android:strokeColor="#020BAC"
android:strokeLineCap="round"/>
<path
android:pathData="M108,69H118C120.21,69 122,70.79 122,73V82"
android:strokeWidth="3"
android:fillColor="#00000000"
android:strokeColor="#020BAC"
android:strokeLineCap="round"/>
<path
android:pathData="M76,129H66C63.79,129 62,127.21 62,125V116"
android:strokeWidth="3"
android:fillColor="#00000000"
android:strokeColor="#020BAC"
android:strokeLineCap="round"/>
<path
android:pathData="M108,129H118C120.21,129 122,127.21 122,125V116"
android:strokeWidth="3"
android:fillColor="#00000000"
android:strokeColor="#020BAC"
android:strokeLineCap="round"/>
</vector>

View File

@@ -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">
<argument
android:name="forQuickRestore"
android:defaultValue="false"
app:argType="boolean" />
</action>
<action
android:id="@+id/action_direct_to_chatFoldersFragment"
@@ -1182,6 +1187,11 @@
android:name="backup_later_selected"
app:argType="boolean"
android:defaultValue="false" />
<argument
android:name="forQuickRestore"
app:argType="boolean"
android:defaultValue="false" />
</fragment>
<fragment

View File

@@ -8474,6 +8474,14 @@
<!-- Button label to open storage management screen -->
<string name="RemoteBackupsSettingsFragment__manage_storage">Manage storage</string>
<!-- ReadyToTransferBottomSheet -->
<!-- Title for a bottom sheet letting people know that they can transfer their back to their new device -->
<string name="RemoteBackupsSettingsFragment__ready_to_transfer">Ready to transfer</string>
<!-- Body for bottom sheet telling the user to scan a QR code to continue transferring their backup to a new device -->
<string name="RemoteBackupsSettingsFragment__use_this_device_to_scan_qr">Use this device to scan the QR code on the device you want to transfer to</string>
<!-- Button label to open QR scanner to scan a device and transfer a backup -->
<string name="RemoteBackupsSettingsFragment__scan_qr_code">Scan QR code</string>
<!-- DownloadYourBackupTodayDialog -->
<!-- Dialog title -->
<string name="DownloadYourBackupTodayDialog__download_your_backup_today">Download your backup today</string>
@@ -8831,6 +8839,19 @@
<!-- Old device Transfer account bottom sheet dialog body -->
<string name="TransferAccount_continue_on_your_other_device_details">Continue transferring your account on your other device.</string>
<!-- Prepare Device screen title for quick restore flow -->
<string name="PrepareDevice_title">Getting your device ready</string>
<!-- Prepare Device screen body text explaining backup recommendation -->
<string name="PrepareDevice_body">Back up now before transferring. You may have received messages that haven\'t been backed up yet.</string>
<!-- Prepare Device screen last backup text format, %1$s is the date and %2$s is the time -->
<string name="PrepareDevice_last_backup">Your last backup was made on %1$s at %2$s.</string>
<!-- Prepare Device screen back up now button -->
<string name="PrepareDevice_back_up_now">Back up now</string>
<!-- Prepare Device screen skip and continue button -->
<string name="PrepareDevice_skip_and_continue">Skip and continue</string>
<!-- A string describing the last time the user's backup was made. The placeholder represents a date-time. An example would be "Your last backup was made on July 18, 2025 at 9:00am." -->
<string name="PrepareDevice_last_backup_description">Your last backup was made %1$s. </string>
<!-- Restore Complete bottom sheet dialog title -->
<string name="RestoreCompleteBottomSheet_restore_complete">Finish on your other device</string>
<!-- Restore Complete bottom sheet dialog message -->

View File

@@ -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)

View File

@@ -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.
*

View File

@@ -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<Scene<NavKey>>.() -> 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<Scene<NavKey>>.() -> 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<Scene<NavKey>>.(@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))
)
}
}
}

View File

@@ -6522,6 +6522,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="7b1130efacb6c427bd59e39908c48cb8289367de4d804da6945e8d82c992f743" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.navigation3" name="navigation3-ui-jvmstubs" version="1.0.0">
<artifact name="navigation3-ui-jvmstubs-1.0.0.jar">
<sha256 value="40cc06d757170517c9b294018e1f7db7d19471a7b423d7e6af6744e8a2388359" origin="Generated by Gradle"/>
</artifact>
<artifact name="navigation3-ui-jvmstubs-1.0.0.module">
<sha256 value="f2b80e87ee5318b8bffd682d51c7757ad4051fb1ab12a6ad391abcb967228c0d" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.navigationevent" name="navigationevent" version="1.0.0">
<artifact name="navigationevent-1.0.0.module">
<md5 value="172a41defba3797b4a6958eabacbb374" origin="Generated by Gradle"/>