mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-15 07:28:30 +00:00
Prompt users to backup during quick restore.
This commit is contained in:
committed by
Alex Hart
parent
4921198cd8
commit
9012a2afc0
@@ -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)
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
24
app/src/main/res/drawable/illustration_prepare_backup.xml
Normal file
24
app/src/main/res/drawable/illustration_prepare_backup.xml
Normal 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>
|
||||
110
app/src/main/res/drawable/illustration_scan_qr_transfer.xml
Normal file
110
app/src/main/res/drawable/illustration_scan_qr_transfer.xml
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"/>
|
||||
|
||||
Reference in New Issue
Block a user