mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-19 00:01:08 +01:00
Prompt users to backup during quick restore.
This commit is contained in:
committed by
Alex Hart
parent
4921198cd8
commit
9012a2afc0
@@ -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)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user