Pre-Registration Restoration from Local Unified Backup.

This commit is contained in:
Alex Hart
2026-02-20 11:14:58 -04:00
committed by Cody Henthorne
parent 7e605fb6de
commit c9dd332abd
47 changed files with 2238 additions and 869 deletions

View File

@@ -23,7 +23,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.service.KeyCachingService
import org.thoughtcrime.securesms.util.CachedInflater
import org.thoughtcrime.securesms.util.DynamicTheme
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.SignalE164Util
import org.thoughtcrime.securesms.util.navigation.safeNavigate
@@ -52,7 +52,7 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
when (val appSettingsRoute: AppSettingsRoute? = intent?.getParcelableExtraCompat(START_ROUTE, AppSettingsRoute::class.java)) {
AppSettingsRoute.Empty -> null
is AppSettingsRoute.BackupsRoute.Local -> {
if (SignalStore.backup.newLocalBackupsEnabled || RemoteConfig.unifiedLocalBackups && (!SignalStore.settings.isBackupEnabled || appSettingsRoute.triggerUpdateFlow)) {
if (SignalStore.backup.newLocalBackupsEnabled || (Environment.Backups.isNewFormatSupportedForLocalBackup() && (!SignalStore.settings.isBackupEnabled || appSettingsRoute.triggerUpdateFlow))) {
AppSettingsFragmentDirections.actionDirectToLocalBackupsFragment()
.setTriggerUpdateFlow(appSettingsRoute.triggerUpdateFlow)
} else {

View File

@@ -57,7 +57,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.MessageBa
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import java.math.BigDecimal
import java.util.Currency
@@ -104,13 +104,12 @@ class BackupsSettingsFragment : ComposeFragment() {
}
},
onOnDeviceBackupsRowClick = {
if (SignalStore.backup.newLocalBackupsEnabled || RemoteConfig.unifiedLocalBackups && !SignalStore.settings.isBackupEnabled) {
if (SignalStore.backup.newLocalBackupsEnabled || (Environment.Backups.isNewFormatSupportedForLocalBackup() && !SignalStore.settings.isBackupEnabled)) {
findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_localBackupsFragment)
} else {
findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_backupsPreferenceFragment)
}
},
onNewOnDeviceBackupsRowClick = { findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_internalLocalBackupFragment) },
onBackupTierInternalOverrideChanged = { viewModel.onBackupTierInternalOverrideChanged(it) }
)
}
@@ -122,7 +121,6 @@ private fun BackupsSettingsContent(
onNavigationClick: () -> Unit = {},
onBackupsRowClick: () -> Unit = {},
onOnDeviceBackupsRowClick: () -> Unit = {},
onNewOnDeviceBackupsRowClick: () -> Unit = {},
onBackupTierInternalOverrideChanged: (MessageBackupTier?) -> Unit = {}
) {
Scaffolds.Settings(
@@ -241,16 +239,6 @@ private fun BackupsSettingsContent(
onClick = onOnDeviceBackupsRowClick
)
}
if (backupsSettingsState.showNewLocalBackup) {
item {
Rows.TextRow(
text = "INTERNAL ONLY - New Local Backup",
label = "Use new local backup format",
onClick = onNewOnDeviceBackupsRowClick
)
}
}
}
}
}

View File

@@ -17,6 +17,5 @@ data class BackupsSettingsState(
val backupState: BackupState,
val lastBackupAt: Duration = SignalStore.backup.lastBackupTime.milliseconds,
val showBackupTierInternalOverride: Boolean = false,
val backupTierInternalOverride: MessageBackupTier? = null,
val showNewLocalBackup: Boolean = false
val backupTierInternalOverride: MessageBackupTier? = null
)

View File

@@ -21,7 +21,6 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.RemoteConfig
import kotlin.time.Duration.Companion.milliseconds
class BackupsSettingsViewModel : ViewModel() {
@@ -46,8 +45,7 @@ class BackupsSettingsViewModel : ViewModel() {
backupState = enabledState,
lastBackupAt = SignalStore.backup.lastBackupTime.milliseconds,
showBackupTierInternalOverride = Environment.IS_STAGING,
backupTierInternalOverride = SignalStore.backup.backupTierInternalOverride,
showNewLocalBackup = RemoteConfig.internalUser || Environment.IS_NIGHTLY
backupTierInternalOverride = SignalStore.backup.backupTierInternalOverride
)
}
}

View File

@@ -1,275 +0,0 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.backups.local
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.DocumentsContract
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.map
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import org.signal.core.ui.compose.ComposeFragment
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Rows
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.util.StorageUtil
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.LocalBackupV2Event
import org.thoughtcrime.securesms.conversation.v2.registerForLifecycle
import org.thoughtcrime.securesms.jobs.LocalBackupJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.service.LocalBackupListener
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.formatHours
import java.time.LocalTime
import java.util.Locale
/**
* App settings internal screen for enabling and creating new local backups.
*/
class InternalNewLocalBackupCreateFragment : ComposeFragment() {
private val TAG = Log.tag(InternalNewLocalBackupCreateFragment::class)
private lateinit var chooseBackupLocationLauncher: ActivityResultLauncher<Intent>
private var createStatus by mutableStateOf("None")
private val directoryFlow = SignalStore.backup.newLocalBackupsDirectoryFlow.map { if (Build.VERSION.SDK_INT >= 24 && it != null) StorageUtil.getDisplayPath(requireContext(), Uri.parse(it)) else it }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
chooseBackupLocationLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK && result.data?.data != null) {
handleBackupLocationSelected(result.data!!.data!!)
} else {
Log.w(TAG, "Backup location selection cancelled or failed")
}
}
EventBus.getDefault().registerForLifecycle(subscriber = this, lifecycleOwner = this)
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun onEvent(event: LocalBackupV2Event) {
createStatus = "${event.type}: ${event.count} / ${event.estimatedTotalCount}"
}
@Composable
override fun FragmentContent() {
val context = LocalContext.current
val backupsEnabled by SignalStore.backup.newLocalBackupsEnabledFlow.collectAsStateWithLifecycle(SignalStore.backup.newLocalBackupsEnabled)
val selectedDirectory by directoryFlow.collectAsStateWithLifecycle(SignalStore.backup.newLocalBackupsDirectory)
val lastBackupTime by SignalStore.backup.newLocalBackupsLastBackupTimeFlow.collectAsStateWithLifecycle(SignalStore.backup.newLocalBackupsLastBackupTime)
val lastBackupTimeString = remember(lastBackupTime) { calculateLastBackupTimeString(context, lastBackupTime) }
val backupTime = remember { LocalTime.of(SignalStore.settings.backupHour, SignalStore.settings.backupMinute).formatHours(requireContext()) }
InternalLocalBackupScreen(
backupsEnabled = backupsEnabled,
selectedDirectory = selectedDirectory,
lastBackupTimeString = lastBackupTimeString,
backupTime = backupTime,
createStatus = createStatus,
callback = CallbackImpl()
)
}
private fun launchBackupDirectoryPicker() {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
if (Build.VERSION.SDK_INT >= 26) {
val latestDirectory = SignalStore.settings.latestSignalBackupDirectory
if (latestDirectory != null) {
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, latestDirectory)
}
}
intent.addFlags(
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
try {
Log.d(TAG, "Launching backup directory picker")
chooseBackupLocationLauncher.launch(intent)
} catch (e: Exception) {
Log.w(TAG, "Failed to launch backup directory picker", e)
Toast.makeText(requireContext(), R.string.BackupDialog_no_file_picker_available, Toast.LENGTH_LONG).show()
}
}
private fun handleBackupLocationSelected(uri: Uri) {
Log.i(TAG, "Backup location selected: $uri")
val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags)
SignalStore.backup.newLocalBackupsDirectory = uri.toString()
Toast.makeText(requireContext(), "Directory selected: $uri", Toast.LENGTH_SHORT).show()
}
private fun calculateLastBackupTimeString(context: Context, lastBackupTimestamp: Long): String {
return if (lastBackupTimestamp > 0) {
val relativeTime = DateUtils.getDatelessRelativeTimeSpanFormattedDate(
context,
Locale.getDefault(),
lastBackupTimestamp
)
if (relativeTime.isRelative) {
relativeTime.value
} else {
val day = DateUtils.getDayPrecisionTimeString(context, Locale.getDefault(), lastBackupTimestamp)
val time = relativeTime.value
context.getString(R.string.RemoteBackupsSettingsFragment__s_at_s, day, time)
}
} else {
context.getString(R.string.RemoteBackupsSettingsFragment__never)
}
}
private inner class CallbackImpl : Callback {
override fun onNavigationClick() {
requireActivity().onBackPressedDispatcher.onBackPressed()
}
override fun onToggleBackupsClick(enabled: Boolean) {
SignalStore.backup.newLocalBackupsEnabled = enabled
if (enabled) {
LocalBackupListener.schedule(requireContext())
}
}
override fun onSelectDirectoryClick() {
launchBackupDirectoryPicker()
}
override fun onEnqueueBackupClick() {
createStatus = "Starting..."
LocalBackupJob.enqueueArchive(false)
}
}
}
private interface Callback {
fun onNavigationClick()
fun onToggleBackupsClick(enabled: Boolean)
fun onSelectDirectoryClick()
fun onEnqueueBackupClick()
object Empty : Callback {
override fun onNavigationClick() = Unit
override fun onToggleBackupsClick(enabled: Boolean) = Unit
override fun onSelectDirectoryClick() = Unit
override fun onEnqueueBackupClick() = Unit
}
}
@Composable
private fun InternalLocalBackupScreen(
backupsEnabled: Boolean = false,
selectedDirectory: String? = null,
lastBackupTimeString: String = "Never",
backupTime: String = "Unknown",
createStatus: String = "None",
callback: Callback
) {
Scaffolds.Settings(
title = "New Local Backups",
navigationIcon = SignalIcons.ArrowStart.imageVector,
onNavigationClick = callback::onNavigationClick
) { paddingValues ->
LazyColumn(
modifier = Modifier.padding(paddingValues)
) {
item {
Rows.ToggleRow(
checked = backupsEnabled,
text = "Enable New Local Backups",
label = if (backupsEnabled) "Backups are enabled" else "Backups are disabled",
onCheckChanged = callback::onToggleBackupsClick
)
}
item {
Rows.TextRow(
text = "Last Backup",
label = lastBackupTimeString
)
}
item {
Rows.TextRow(
text = "Backup Schedule Time (same as v1)",
label = backupTime
)
}
item {
Rows.TextRow(
text = "Select Backup Directory",
label = selectedDirectory ?: "No directory selected",
onClick = callback::onSelectDirectoryClick
)
}
item {
Rows.TextRow(
text = "Create Backup Now",
label = "Enqueue LocalArchiveJob",
onClick = callback::onEnqueueBackupClick
)
}
item {
Rows.TextRow(
text = "Create Status",
label = createStatus
)
}
}
}
}
@DayNightPreviews
@Composable
fun InternalLocalBackupScreenPreview() {
Previews.Preview {
InternalLocalBackupScreen(
backupsEnabled = true,
selectedDirectory = "/storage/emulated/0/Signal/Backups",
lastBackupTimeString = "1 hour ago",
callback = Callback.Empty
)
}
}

View File

@@ -4,13 +4,11 @@
*/
package org.thoughtcrime.securesms.components.settings.app.backups.local
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@@ -31,6 +29,7 @@ import androidx.navigation3.ui.NavDisplay
import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.ComposeFragment
import org.signal.core.ui.compose.Launchers
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsKeyEducationScreen
@@ -158,11 +157,10 @@ class LocalBackupsFragment : ComposeFragment() {
}
@Composable
private fun rememberChooseBackupLocationLauncher(backStack: NavBackStack<NavKey>): ActivityResultLauncher<Intent> {
private fun rememberChooseBackupLocationLauncher(backStack: NavBackStack<NavKey>): ActivityResultLauncher<Uri?> {
val context = LocalContext.current
return rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val uri = result.data?.data
if (result.resultCode == Activity.RESULT_OK && uri != null) {
return Launchers.rememberOpenDocumentTreeLauncher { uri ->
if (uri != null) {
Log.i(TAG, "Backup location selected: $uri")
val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
context.contentResolver.takePersistableUriPermission(uri, takeFlags)

View File

@@ -6,9 +6,7 @@ package org.thoughtcrime.securesms.components.settings.app.backups.local
import android.Manifest
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Build
import android.provider.DocumentsContract
import android.net.Uri
import android.text.format.DateFormat
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
@@ -52,7 +50,7 @@ sealed interface LocalBackupsSettingsCallback {
class DefaultLocalBackupsSettingsCallback(
private val fragment: LocalBackupsFragment,
private val chooseBackupLocationLauncher: ActivityResultLauncher<Intent>,
private val chooseBackupLocationLauncher: ActivityResultLauncher<Uri?>,
private val viewModel: LocalBackupsViewModel
) : LocalBackupsSettingsCallback {
@@ -65,22 +63,10 @@ class DefaultLocalBackupsSettingsCallback(
}
override fun onLaunchBackupLocationPickerClick() {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
if (Build.VERSION.SDK_INT >= 26) {
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, SignalStore.settings.latestSignalBackupDirectory)
}
intent.addFlags(
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
try {
Log.d(TAG, "Starting choose backup location dialog")
chooseBackupLocationLauncher.launch(intent)
} catch (e: ActivityNotFoundException) {
chooseBackupLocationLauncher.launch(SignalStore.settings.latestSignalBackupDirectory)
} catch (_: ActivityNotFoundException) {
Toast.makeText(fragment.requireContext(), R.string.BackupDialog_no_file_picker_available, Toast.LENGTH_LONG).show()
}
}

View File

@@ -27,7 +27,6 @@ import org.thoughtcrime.securesms.jobs.LocalBackupJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.BackupUtil
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.formatHours
import java.text.NumberFormat
@@ -96,7 +95,6 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
val clientDeprecated = SignalStore.misc.isClientDeprecated
val legacyLocalBackupsEnabled = SignalStore.settings.isBackupEnabled && BackupUtil.canUserAccessBackupDirectory(context)
val canTurnOn = legacyLocalBackupsEnabled || (!userUnregistered && !clientDeprecated)
val isLegacyBackup = !RemoteConfig.unifiedLocalBackups || (SignalStore.settings.isBackupEnabled && !SignalStore.backup.newLocalBackupsEnabled)
if (SignalStore.backup.newLocalBackupsEnabled) {
if (!BackupUtil.canUserAccessUnifiedBackupDirectory(context)) {

View File

@@ -84,7 +84,7 @@ import org.thoughtcrime.securesms.jobs.BackupRestoreMediaJob
import org.thoughtcrime.securesms.jobs.LocalBackupJob
import org.thoughtcrime.securesms.keyvalue.BackupValues
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.registration.ui.restore.local.InternalNewLocalRestoreActivity
import org.thoughtcrime.securesms.registration.ui.restore.local.RestoreLocalBackupActivity
class InternalBackupPlaygroundFragment : ComposeFragment() {
@@ -230,7 +230,7 @@ class InternalBackupPlaygroundFragment : ComposeFragment() {
.setTitle("Are you sure?")
.setMessage("After you choose a file to import, this will delete all of your chats, then restore them from the file! Only do this on a test device!")
.setPositiveButton("Wipe and restore") { _, _ ->
startActivity(InternalNewLocalRestoreActivity.getIntent(context, finish = false))
startActivity(RestoreLocalBackupActivity.getIntent(context, finish = false))
}
.show()
},

View File

@@ -103,6 +103,7 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) {
private const val KEY_NEW_LOCAL_BACKUPS_ENABLED = "backup.new_local_backups_enabled"
private const val KEY_NEW_LOCAL_BACKUPS_DIRECTORY = "backup.new_local_backups_directory"
private const val KEY_NEW_LOCAL_BACKUPS_LAST_BACKUP_TIME = "backup.new_local_backups_last_backup_time"
private const val KEY_NEW_LOCAL_BACKUPS_SELECTED_SNAPSHOT_TIMESTAMP = "backup.new_local_backups_selected_snapshot_timestamp"
private const val KEY_UPLOAD_BANNER_VISIBLE = "backup.upload_banner_visible"
@@ -486,6 +487,11 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) {
var newLocalBackupsLastBackupTime: Long by newLocalBackupsLastBackupTimeValue
val newLocalBackupsLastBackupTimeFlow: Flow<Long> by lazy { newLocalBackupsLastBackupTimeValue.toFlow() }
/**
* The snapshot timestamp selected for restore. Set before launching restore, cleared after completion.
*/
var newLocalBackupsSelectedSnapshotTimestamp: Long by longValue(KEY_NEW_LOCAL_BACKUPS_SELECTED_SNAPSHOT_TIMESTAMP, -1L)
/**
* When we are told by the server that we are out of storage space, we should show
* UX treatment to make the user aware of this.

View File

@@ -596,7 +596,7 @@ public final class Megaphones {
}
private static boolean shouldShowUseNewOnDeviceBackupsMegaphone() {
return RemoteConfig.unifiedLocalBackups() && SignalStore.settings().isBackupEnabled();
return Environment.Backups.isNewFormatSupportedForLocalBackup() && SignalStore.settings().isBackupEnabled();
}
private static boolean shouldShowGrantFullScreenIntentPermission(@NonNull Context context) {

View File

@@ -29,22 +29,22 @@ import com.google.android.material.timepicker.TimeFormat;
import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
import org.signal.core.ui.permissions.Permissions;
import org.signal.core.ui.util.StorageUtil;
import org.signal.core.util.NoExternalStorageException;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.backup.BackupDialog;
import org.thoughtcrime.securesms.backup.BackupEvent;
import org.signal.core.util.NoExternalStorageException;
import org.thoughtcrime.securesms.dependencies.AppDependencies;
import org.thoughtcrime.securesms.jobs.LocalBackupJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.signal.core.ui.permissions.Permissions;
import org.thoughtcrime.securesms.preferences.widgets.UpgradeLocalBackupCard;
import org.thoughtcrime.securesms.service.LocalBackupListener;
import org.thoughtcrime.securesms.util.BackupUtil;
import org.thoughtcrime.securesms.util.Environment;
import org.thoughtcrime.securesms.util.JavaTimeExtensionsKt;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.signal.core.ui.util.StorageUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import java.text.NumberFormat;
@@ -231,7 +231,7 @@ public class BackupsPreferenceFragment extends Fragment {
}
private void setUpdateState() {
if (SignalStore.settings().isBackupEnabled() && RemoteConfig.unifiedLocalBackups()) {
if (SignalStore.settings().isBackupEnabled() && Environment.Backups.isNewFormatSupportedForLocalBackup()) {
UpgradeLocalBackupCard.bind(upgradeCard, () -> {
Navigation.findNavController(requireView())
.navigate(BackupsPreferenceFragmentDirections.actionBackupsPreferenceFragmentToLocalBackupsFragment()

View File

@@ -562,7 +562,7 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_
EnterPhoneNumberMode.RESTART_AFTER_COLLECTION -> startNormalRegistration()
EnterPhoneNumberMode.COLLECT_FOR_MANUAL_SIGNAL_BACKUPS_RESTORE -> findNavController().safeNavigate(EnterPhoneNumberFragmentDirections.goToEnterBackupKey())
EnterPhoneNumberMode.COLLECT_FOR_LOCAL_V2_SIGNAL_BACKUPS_RESTORE -> findNavController().safeNavigate(EnterPhoneNumberFragmentDirections.goToInternalNewLocalBackupRestore())
EnterPhoneNumberMode.COLLECT_FOR_LOCAL_V2_SIGNAL_BACKUPS_RESTORE -> findNavController().safeNavigate(EnterPhoneNumberFragmentDirections.goToRestoreLocalBackupFragment())
}
}

View File

@@ -32,7 +32,6 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.contactsupport.ContactSupportDialog
import org.thoughtcrime.securesms.components.contactsupport.ContactSupportViewModel
import org.thoughtcrime.securesms.components.contactsupport.SendSupportEmailEffect
import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
import org.thoughtcrime.securesms.registration.ui.RegistrationCheckpoint
import org.thoughtcrime.securesms.registration.ui.RegistrationViewModel
import org.thoughtcrime.securesms.registration.ui.phonenumber.EnterPhoneNumberMode
@@ -218,30 +217,12 @@ private fun ErrorContent(
onDismiss = onBackupTierNotRestoredDismiss,
properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false)
)
} else if (state.showRegistrationError) {
if (state.registerAccountResult is RegisterAccountResult.IncorrectRecoveryPassword) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.EnterBackupKey_incorrect_backup_key_title),
body = stringResource(R.string.EnterBackupKey_incorrect_backup_key_message),
confirm = stringResource(R.string.EnterBackupKey_try_again),
dismiss = stringResource(R.string.EnterBackupKey_backup_key_help),
onConfirm = {},
onDeny = onBackupKeyHelp,
onDismiss = onRegistrationErrorDismiss,
properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false)
)
} else {
val message = when (state.registerAccountResult) {
is RegisterAccountResult.RateLimited -> stringResource(R.string.RegistrationActivity_you_have_made_too_many_attempts_please_try_again_later)
else -> stringResource(R.string.RegistrationActivity_error_connecting_to_service)
}
Dialogs.SimpleMessageDialog(
message = message,
onDismiss = onRegistrationErrorDismiss,
dismiss = stringResource(android.R.string.ok),
properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false)
)
}
} else {
RegistrationErrorDialogs(
showRegistrationError = state.showRegistrationError,
registerAccountResult = state.registerAccountResult,
onRegistrationErrorDismiss = onRegistrationErrorDismiss,
onBackupKeyHelp = onBackupKeyHelp
)
}
}

View File

@@ -0,0 +1,52 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.restore
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.window.DialogProperties
import org.signal.core.ui.compose.Dialogs
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
/**
* Shared error dialogs for registration failures during backup key entry.
* Used by both remote and local backup restore flows.
*/
@Composable
fun RegistrationErrorDialogs(
showRegistrationError: Boolean,
registerAccountResult: RegisterAccountResult?,
onRegistrationErrorDismiss: () -> Unit,
onBackupKeyHelp: () -> Unit
) {
if (!showRegistrationError) return
if (registerAccountResult is RegisterAccountResult.IncorrectRecoveryPassword) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.EnterBackupKey_incorrect_backup_key_title),
body = stringResource(R.string.EnterBackupKey_incorrect_backup_key_message),
confirm = stringResource(R.string.EnterBackupKey_try_again),
dismiss = stringResource(R.string.EnterBackupKey_backup_key_help),
onConfirm = {},
onDeny = onBackupKeyHelp,
onDismiss = onRegistrationErrorDismiss,
properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false)
)
} else {
val message = when (registerAccountResult) {
is RegisterAccountResult.RateLimited -> stringResource(R.string.RegistrationActivity_you_have_made_too_many_attempts_please_try_again_later)
else -> stringResource(R.string.RegistrationActivity_error_connecting_to_service)
}
Dialogs.SimpleMessageDialog(
message = message,
onDismiss = onRegistrationErrorDismiss,
dismiss = stringResource(android.R.string.ok),
properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false)
)
}
}

View File

@@ -13,18 +13,18 @@ import org.thoughtcrime.securesms.R
enum class RestoreMethod(val iconRes: Int, val titleRes: Int, val subtitleRes: Int) {
FROM_SIGNAL_BACKUPS(
iconRes = R.drawable.symbol_signal_backups_24,
titleRes = R.string.SelectRestoreMethodFragment__from_signal_backups,
subtitleRes = R.string.SelectRestoreMethodFragment__your_free_or_paid_signal_backup_plan
titleRes = R.string.SelectRestoreMethodFragment__restore_signal_backup,
subtitleRes = R.string.SelectRestoreMethodFragment__restore_your_text_messages_and_media_from
),
FROM_LOCAL_BACKUP_V1(
iconRes = R.drawable.symbol_file_24,
titleRes = R.string.SelectRestoreMethodFragment__from_a_backup_file,
subtitleRes = R.string.SelectRestoreMethodFragment__choose_a_backup_youve_saved
titleRes = R.string.SelectRestoreMethodFragment__restore_on_device_backup,
subtitleRes = R.string.SelectRestoreMethodFragment__restore_your_messages_from
),
FROM_LOCAL_BACKUP_V2(
iconRes = R.drawable.symbol_folder_24,
titleRes = R.string.SelectRestoreMethodFragment__from_a_backup_folder,
subtitleRes = R.string.SelectRestoreMethodFragment__choose_a_backup_youve_saved
titleRes = R.string.SelectRestoreMethodFragment__restore_on_device_backup,
subtitleRes = R.string.SelectRestoreMethodFragment__restore_your_messages_from
),
FROM_OLD_DEVICE(
iconRes = R.drawable.symbol_transfer_24,

View File

@@ -79,8 +79,8 @@ private fun RestoreMethodRowPreview() {
Previews.Preview {
RestoreRow(
icon = SignalIcons.Backup.painter,
title = stringResource(R.string.SelectRestoreMethodFragment__from_signal_backups),
subtitle = stringResource(R.string.SelectRestoreMethodFragment__your_free_or_paid_signal_backup_plan)
title = stringResource(R.string.SelectRestoreMethodFragment__restore_signal_backup),
subtitle = stringResource(R.string.SelectRestoreMethodFragment__restore_your_text_messages_and_media_from)
)
}
}

View File

@@ -21,7 +21,6 @@ import androidx.navigation.fragment.findNavController
import org.signal.core.ui.compose.ComposeFragment
import org.signal.core.ui.compose.Dialogs
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.registration.ui.RegistrationViewModel
import org.thoughtcrime.securesms.registration.ui.phonenumber.EnterPhoneNumberMode
@@ -58,8 +57,8 @@ class SelectManualRestoreMethodFragment : ComposeFragment() {
var showSkipRestoreWarning by remember { mutableStateOf(false) }
val restoreMethods = remember {
if (Environment.IS_NIGHTLY || BuildConfig.DEBUG) {
listOf(RestoreMethod.FROM_SIGNAL_BACKUPS, RestoreMethod.FROM_LOCAL_BACKUP_V1, RestoreMethod.FROM_LOCAL_BACKUP_V2)
if (Environment.Backups.isNewFormatSupportedForLocalBackup()) {
listOf(RestoreMethod.FROM_SIGNAL_BACKUPS, RestoreMethod.FROM_LOCAL_BACKUP_V2)
} else {
listOf(RestoreMethod.FROM_SIGNAL_BACKUPS, RestoreMethod.FROM_LOCAL_BACKUP_V1)
}

View File

@@ -0,0 +1,169 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.restore.local
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.sp
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.CircularProgressWrapper
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.fonts.MonoTypeface
import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
import org.thoughtcrime.securesms.registration.ui.restore.AccountEntropyPoolVerification
import org.thoughtcrime.securesms.registration.ui.restore.BackupKeyVisualTransformation
import org.thoughtcrime.securesms.registration.ui.restore.RegistrationErrorDialogs
import org.thoughtcrime.securesms.registration.ui.restore.attachBackupKeyAutoFillHelper
import org.thoughtcrime.securesms.registration.ui.restore.backupKeyAutoFillHelper
import org.thoughtcrime.securesms.registration.ui.shared.RegistrationScreen
@Composable
fun EnterLocalBackupKeyScreen(
backupKey: String,
isRegistrationInProgress: Boolean,
isBackupKeyValid: Boolean,
aepValidationError: AccountEntropyPoolVerification.AEPValidationError?,
onBackupKeyChanged: (String) -> Unit,
onNextClicked: () -> Unit,
onNoBackupKeyClick: () -> Unit,
showRegistrationError: Boolean = false,
registerAccountResult: RegisterAccountResult? = null,
onRegistrationErrorDismiss: () -> Unit = {},
onBackupKeyHelp: () -> Unit = {}
) {
val visualTransform = remember { BackupKeyVisualTransformation(chunkSize = 4) }
val keyboardController = LocalSoftwareKeyboardController.current
val focusRequester = remember { FocusRequester() }
var requestFocus by remember { mutableStateOf(true) }
val autoFillHelper = backupKeyAutoFillHelper { onBackupKeyChanged(it) }
RegistrationScreen(
title = stringResource(R.string.EnterLocalBackupKeyScreen__enter_your_recovery_key),
subtitle = stringResource(R.string.EnterLocalBackupKeyScreen__your_recovery_key_is_a_64_character_code),
bottomContent = {
Row {
TextButton(
onClick = onNoBackupKeyClick,
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.outline)
) {
Text(text = stringResource(R.string.EnterLocalBackupKeyScreen__no_backup_key))
}
Spacer(modifier = Modifier.weight(1f))
CircularProgressWrapper(
isLoading = isRegistrationInProgress
) {
Buttons.LargeTonal(
onClick = onNextClicked,
enabled = isBackupKeyValid && aepValidationError == null
) {
Text(text = stringResource(R.string.EnterLocalBackupKeyScreen__next))
}
}
}
}
) {
TextField(
value = backupKey,
onValueChange = { value ->
onBackupKeyChanged(value)
autoFillHelper.onValueChanged(value)
},
label = {
Text(text = stringResource(id = R.string.EnterBackupKey_backup_key))
},
textStyle = LocalTextStyle.current.copy(
fontFamily = MonoTypeface.fontFamily(),
lineHeight = 36.sp
),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done,
autoCorrectEnabled = false
),
keyboardActions = KeyboardActions(
onDone = {
if (isBackupKeyValid && aepValidationError == null) {
keyboardController?.hide()
onNextClicked()
}
}
),
supportingText = { aepValidationError?.let { ValidationErrorMessage(it) } },
isError = aepValidationError != null,
minLines = 4,
visualTransformation = visualTransform,
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.attachBackupKeyAutoFillHelper(autoFillHelper)
.onGloballyPositioned {
if (requestFocus) {
focusRequester.requestFocus()
requestFocus = false
}
}
)
RegistrationErrorDialogs(
showRegistrationError = showRegistrationError,
registerAccountResult = registerAccountResult,
onRegistrationErrorDismiss = onRegistrationErrorDismiss,
onBackupKeyHelp = onBackupKeyHelp
)
}
}
@Composable
private fun ValidationErrorMessage(error: AccountEntropyPoolVerification.AEPValidationError) {
when (error) {
is AccountEntropyPoolVerification.AEPValidationError.TooLong -> Text(text = stringResource(R.string.EnterBackupKey_too_long_error, error.count, error.max))
AccountEntropyPoolVerification.AEPValidationError.Invalid -> Text(text = stringResource(R.string.EnterBackupKey_invalid_backup_key_error))
AccountEntropyPoolVerification.AEPValidationError.Incorrect -> Text(text = stringResource(R.string.EnterBackupKey_incorrect_backup_key_error))
}
}
@DayNightPreviews
@Composable
private fun EnterLocalBackupKeyScreenPreview() {
Previews.Preview {
EnterLocalBackupKeyScreen(
backupKey = "",
isRegistrationInProgress = false,
isBackupKeyValid = false,
aepValidationError = null,
onBackupKeyChanged = {},
onNextClicked = {},
onNoBackupKeyClick = {}
)
}
}

View File

@@ -1,312 +0,0 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.restore.local
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.DocumentsContract
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.map
import org.signal.core.models.AccountEntropyPool
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.ComposeFragment
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.util.StorageUtil
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.fonts.MonoTypeface
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.registration.ui.RegistrationViewModel
import org.thoughtcrime.securesms.registration.ui.restore.AccountEntropyPoolVerification
import org.thoughtcrime.securesms.registration.ui.restore.BackupKeyVisualTransformation
import org.thoughtcrime.securesms.registration.ui.restore.attachBackupKeyAutoFillHelper
import org.thoughtcrime.securesms.registration.ui.restore.backupKeyAutoFillHelper
import org.thoughtcrime.securesms.registration.ui.shared.RegistrationScreen
/**
* Internal only registration screen to collect backup folder and AEP. Actual restore will happen
* post-registration when the app re-routes to [org.thoughtcrime.securesms.restore.RestoreActivity] and then
* [InternalNewLocalRestoreActivity]. Yay implicit navigation!
*/
class InternalNewLocalBackupRestore : ComposeFragment() {
private val TAG = Log.tag(InternalNewLocalBackupRestore::class)
private val sharedViewModel by activityViewModels<RegistrationViewModel>()
private lateinit var chooseBackupLocationLauncher: ActivityResultLauncher<Intent>
private val directoryFlow = SignalStore.backup.newLocalBackupsDirectoryFlow.map { if (Build.VERSION.SDK_INT >= 24 && it != null) StorageUtil.getDisplayPath(requireContext(), Uri.parse(it)) else it }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
chooseBackupLocationLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK && result.data?.data != null) {
handleBackupLocationSelected(result.data!!.data!!)
} else {
Log.w(TAG, "Backup location selection cancelled or failed")
}
}
}
@Composable
override fun FragmentContent() {
val selectedDirectory: String? by directoryFlow.collectAsStateWithLifecycle(SignalStore.backup.newLocalBackupsDirectory)
InternalNewLocalBackupRestoreScreen(
selectedDirectory = selectedDirectory,
callback = CallbackImpl()
)
}
private fun launchBackupDirectoryPicker() {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
if (Build.VERSION.SDK_INT >= 26) {
val currentDirectory = SignalStore.backup.newLocalBackupsDirectory
if (currentDirectory != null) {
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, Uri.parse(currentDirectory))
}
}
intent.addFlags(
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
try {
Log.d(TAG, "Launching backup directory picker")
chooseBackupLocationLauncher.launch(intent)
} catch (e: Exception) {
Log.w(TAG, "Failed to launch backup directory picker", e)
Toast.makeText(requireContext(), R.string.BackupDialog_no_file_picker_available, Toast.LENGTH_LONG).show()
}
}
private fun handleBackupLocationSelected(uri: Uri) {
Log.i(TAG, "Backup location selected: $uri")
val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags)
SignalStore.backup.newLocalBackupsDirectory = uri.toString()
Toast.makeText(requireContext(), "Directory selected: $uri", Toast.LENGTH_SHORT).show()
}
private inner class CallbackImpl : Callback {
override fun onSelectDirectoryClick() {
launchBackupDirectoryPicker()
}
override fun onRestoreClick(backupKey: String) {
sharedViewModel.registerWithBackupKey(
context = requireContext(),
backupKey = backupKey,
e164 = null,
pin = null,
aciIdentityKeyPair = null,
pniIdentityKeyPair = null
)
}
}
}
private interface Callback {
fun onSelectDirectoryClick()
fun onRestoreClick(backupKey: String)
object Empty : Callback {
override fun onSelectDirectoryClick() = Unit
override fun onRestoreClick(backupKey: String) = Unit
}
}
@Composable
private fun InternalNewLocalBackupRestoreScreen(
selectedDirectory: String? = null,
callback: Callback
) {
var backupKey by remember { mutableStateOf("") }
var isBackupKeyValid by remember { mutableStateOf(false) }
var aepValidationError by remember { mutableStateOf<AccountEntropyPoolVerification.AEPValidationError?>(null) }
val visualTransform = remember { BackupKeyVisualTransformation(chunkSize = 4) }
val keyboardController = LocalSoftwareKeyboardController.current
val focusRequester = remember { FocusRequester() }
var requestFocus by remember { mutableStateOf(true) }
val autoFillHelper = backupKeyAutoFillHelper { newValue ->
backupKey = newValue
val (valid, error) = AccountEntropyPoolVerification.verifyAEP(
backupKey = newValue,
changed = true,
previousAEPValidationError = aepValidationError
)
isBackupKeyValid = valid
aepValidationError = error
}
RegistrationScreen(
title = "Local Backup V2 Restore",
subtitle = null,
bottomContent = {
Buttons.LargeTonal(
onClick = { callback.onRestoreClick(backupKey) },
enabled = isBackupKeyValid && aepValidationError == null && selectedDirectory != null,
modifier = Modifier.align(Alignment.CenterEnd)
) {
Text(text = "Restore")
}
}
) {
Column(
modifier = Modifier.fillMaxWidth()
) {
DirectorySelectionRow(
selectedDirectory = selectedDirectory,
onClick = callback::onSelectDirectoryClick
)
Spacer(modifier = Modifier.height(24.dp))
TextField(
value = backupKey,
onValueChange = { value ->
val newKey = AccountEntropyPool.removeIllegalCharacters(value).take(AccountEntropyPool.LENGTH + 16).lowercase()
val (valid, error) = AccountEntropyPoolVerification.verifyAEP(
backupKey = newKey,
changed = backupKey != newKey,
previousAEPValidationError = aepValidationError
)
backupKey = newKey
isBackupKeyValid = valid
aepValidationError = error
autoFillHelper.onValueChanged(newKey)
},
label = {
Text(text = stringResource(id = R.string.EnterBackupKey_backup_key))
},
textStyle = LocalTextStyle.current.copy(
fontFamily = MonoTypeface.fontFamily(),
lineHeight = 36.sp
),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done,
autoCorrectEnabled = false
),
keyboardActions = KeyboardActions(
onDone = {
if (isBackupKeyValid && aepValidationError == null && selectedDirectory != null) {
keyboardController?.hide()
callback.onRestoreClick(backupKey)
}
}
),
supportingText = { aepValidationError?.ValidationErrorMessage() },
isError = aepValidationError != null,
minLines = 4,
visualTransformation = visualTransform,
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.attachBackupKeyAutoFillHelper(autoFillHelper)
.onGloballyPositioned {
if (requestFocus) {
focusRequester.requestFocus()
requestFocus = false
}
}
)
}
}
}
@Composable
private fun DirectorySelectionRow(
selectedDirectory: String?,
onClick: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
Text(
text = "Select Backup Directory",
style = MaterialTheme.typography.bodyLarge
)
Text(
text = selectedDirectory ?: "No directory selected",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun AccountEntropyPoolVerification.AEPValidationError.ValidationErrorMessage() {
when (this) {
is AccountEntropyPoolVerification.AEPValidationError.TooLong -> Text(text = stringResource(R.string.EnterBackupKey_too_long_error, this.count, this.max))
AccountEntropyPoolVerification.AEPValidationError.Invalid -> Text(text = stringResource(R.string.EnterBackupKey_invalid_backup_key_error))
AccountEntropyPoolVerification.AEPValidationError.Incorrect -> Text(text = stringResource(R.string.EnterBackupKey_incorrect_backup_key_error))
}
}
@DayNightPreviews
@Composable
private fun InternalNewLocalBackupRestoreScreenPreview() {
Previews.Preview {
InternalNewLocalBackupRestoreScreen(
selectedDirectory = "/storage/emulated/0/Signal/Backups",
callback = Callback.Empty
)
}
}

View File

@@ -1,151 +0,0 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.restore.local
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.widget.Toast
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.Result
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.BaseActivity
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.RestoreV2Event
import org.thoughtcrime.securesms.backup.v2.local.ArchiveFileSystem
import org.thoughtcrime.securesms.backup.v2.local.LocalArchiver
import org.thoughtcrime.securesms.backup.v2.local.SnapshotFileSystem
import org.thoughtcrime.securesms.conversation.v2.registerForLifecycle
import org.thoughtcrime.securesms.database.model.databaseprotos.RestoreDecisionState
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.RestoreLocalAttachmentJob
import org.thoughtcrime.securesms.keyvalue.Completed
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.registration.ui.restore.StorageServiceRestore
import org.thoughtcrime.securesms.registration.ui.shared.RegistrationScreen
import org.thoughtcrime.securesms.registration.util.RegistrationUtil
/**
* Internal only. On launch, attempt to import the most recent backup located in [SignalStore.backup].newLocalBackupsDirectory.
*/
class InternalNewLocalRestoreActivity : BaseActivity() {
companion object {
fun getIntent(context: Context, finish: Boolean = true): Intent = Intent(context, InternalNewLocalRestoreActivity::class.java).apply { putExtra("finish", finish) }
}
private var restoreStatus by mutableStateOf<String>("Unknown")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launch(Dispatchers.IO) {
restoreStatus = "Starting..."
val self = Recipient.self()
val selfData = BackupRepository.SelfData(self.aci.get(), self.pni.get(), self.e164.get(), ProfileKey(self.profileKey))
val archiveFileSystem = ArchiveFileSystem.fromUri(AppDependencies.application, Uri.parse(SignalStore.backup.newLocalBackupsDirectory!!))!!
val snapshotInfo = archiveFileSystem.listSnapshots().first()
val snapshotFileSystem = SnapshotFileSystem(AppDependencies.application, snapshotInfo.file)
val result = LocalArchiver.import(snapshotFileSystem, selfData)
if (result is Result.Success) {
restoreStatus = "Success! Finishing"
val mediaNameToFileInfo = archiveFileSystem.filesFileSystem.allFiles()
RestoreLocalAttachmentJob.enqueueRestoreLocalAttachmentsJobs(mediaNameToFileInfo)
SignalStore.registration.restoreDecisionState = RestoreDecisionState.Completed
SignalStore.backup.backupSecretRestoreRequired = false
StorageServiceRestore.restore()
withContext(Dispatchers.Main) {
Toast.makeText(this@InternalNewLocalRestoreActivity, "Local backup restored!", Toast.LENGTH_SHORT).show()
RegistrationUtil.maybeMarkRegistrationComplete()
startActivity(MainActivity.clearTop(this@InternalNewLocalRestoreActivity))
if (intent.getBooleanExtra("finish", false)) {
finishAffinity()
}
}
} else {
restoreStatus = "Backup failed"
Toast.makeText(this@InternalNewLocalRestoreActivity, "Local backup failed", Toast.LENGTH_SHORT).show()
}
}
setContent {
SignalTheme {
Surface {
InternalNewLocalRestoreScreen(
status = restoreStatus
)
}
}
}
EventBus.getDefault().registerForLifecycle(subscriber = this, lifecycleOwner = this)
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun onEvent(restoreEvent: RestoreV2Event) {
this.restoreStatus = "${restoreEvent.type}: ${restoreEvent.count} / ${restoreEvent.estimatedTotalCount}"
}
}
@Composable
private fun InternalNewLocalRestoreScreen(
status: String = ""
) {
RegistrationScreen(
title = "Internal - Local Restore",
subtitle = null,
bottomContent = { }
) {
Text(
text = status,
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(bottom = 16.dp)
)
CircularProgressIndicator(
modifier = Modifier.align(Alignment.CenterHorizontally)
)
}
}
@DayNightPreviews
@Composable
private fun InternalNewLocalRestorePreview() {
Previews.Preview {
InternalNewLocalRestoreScreen(status = "Importing...")
}
}

View File

@@ -0,0 +1,111 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.restore.local
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
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.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.horizontalGutters
import org.thoughtcrime.securesms.R
/**
* Displayed when the user presses the 'No recovery key?' button on the
* [EnterLocalBackupKeyScreen].
*/
@Composable
fun NoRecoveryKeySheetContent(
onSkipAndDontRestoreClick: () -> Unit,
onLearnMoreClick: () -> Unit = {}
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.symbol_key_24),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier
.padding(top = 56.dp)
.size(88.dp)
.background(color = MaterialTheme.colorScheme.primaryContainer, shape = CircleShape)
.padding(20.dp)
)
Text(
text = "No recovery key?",
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(top = 16.dp)
)
Text(
text = "Backups cant be recovered without their 64-character recovery key. If youve lost your recovery key Signal cant help restore your backup.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier
.horizontalGutters()
.padding(top = 12.dp)
)
Text(
text = "If you have your old device you can view your recovery key in Settings > Backups.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier
.horizontalGutters()
.padding(top = 24.dp, bottom = 56.dp)
)
Row(
modifier = Modifier
.horizontalGutters()
.padding(bottom = 24.dp)
) {
TextButton(
onClick = onLearnMoreClick
) {
Text(text = "Learn more")
}
Spacer(modifier = Modifier.weight(1f))
TextButton(
onClick = onSkipAndDontRestoreClick
) {
Text(text = "Skip and don't restore")
}
}
}
}
@DayNightPreviews
@Composable
fun NoRecoveryKeySheetContentPreview() {
Previews.BottomSheetContentPreview {
NoRecoveryKeySheetContent(
onSkipAndDontRestoreClick = {},
onLearnMoreClick = {}
)
}
}

View File

@@ -0,0 +1,189 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.restore.local
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.layout.BoxWithConstraints
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.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.horizontalGutters
import org.signal.core.ui.compose.theme.SignalTheme
import org.thoughtcrime.securesms.BaseActivity
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import kotlin.math.max
/**
* Handles the synchronous restoration of the proto files from a V2 backup. Media is
* handled by background tasks.
*/
class RestoreLocalBackupActivity : BaseActivity() {
companion object {
private const val KEY_FINISH = "finish"
fun getIntent(context: Context, finish: Boolean = true): Intent {
return Intent(context, RestoreLocalBackupActivity::class.java).apply {
putExtra(KEY_FINISH, finish)
}
}
}
private val viewModel: RestoreLocalBackupActivityViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(state.restorePhase) {
when (state.restorePhase) {
RestorePhase.COMPLETE -> {
startActivity(MainActivity.clearTop(this@RestoreLocalBackupActivity))
if (intent.getBooleanExtra(KEY_FINISH, false)) {
finishAffinity()
}
}
RestorePhase.FAILED -> {
Toast.makeText(this@RestoreLocalBackupActivity, getString(R.string.RestoreLocalBackupActivity__backup_restore_failed), Toast.LENGTH_LONG).show()
}
else -> Unit
}
}
SignalTheme {
RestoreLocalBackupScreen(state = state)
}
}
}
}
@Composable
private fun RestoreLocalBackupScreen(state: RestoreLocalBackupScreenState) {
val density = LocalDensity.current
var headerHeightPx by remember { mutableIntStateOf(0) }
var contentHeightPx by remember { mutableIntStateOf(0) }
Surface {
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
.horizontalGutters()
) {
val totalHeightPx = with(density) { maxHeight.roundToPx() }
val screenCenterTop = with(density) { max((totalHeightPx - contentHeightPx) / 2, headerHeightPx).toDp() }
Column(
modifier = Modifier
.fillMaxWidth()
.onSizeChanged { headerHeightPx = it.height }
) {
Spacer(modifier = Modifier.height(40.dp))
Text(
text = stringResource(R.string.RestoreLocalBackupActivity__restoring_backup),
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.fillMaxWidth()
)
Text(
text = stringResource(R.string.RestoreLocalBackupActivity__depending_on_the_size),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 16.dp)
)
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(top = screenCenterTop)
.onSizeChanged { contentHeightPx = it.height }
) {
if (state.progress > 0f) {
CircularProgressIndicator(
progress = { state.progress },
modifier = Modifier.size(60.dp),
strokeWidth = 5.dp,
trackColor = MaterialTheme.colorScheme.primaryContainer,
gapSize = 0.dp
)
} else {
CircularProgressIndicator(
modifier = Modifier.size(60.dp),
strokeWidth = 5.dp,
trackColor = MaterialTheme.colorScheme.primaryContainer,
gapSize = 0.dp
)
}
val statusText = when (state.restorePhase) {
RestorePhase.RESTORING -> stringResource(R.string.RestoreLocalBackupActivity__restoring_messages)
RestorePhase.FINALIZING -> stringResource(R.string.RestoreLocalBackupActivity__finalizing)
RestorePhase.COMPLETE -> stringResource(R.string.RestoreLocalBackupActivity__restore_complete)
RestorePhase.FAILED -> stringResource(R.string.RestoreLocalBackupActivity__restore_failed)
}
Text(
text = statusText,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(top = 16.dp)
)
if (state.restorePhase == RestorePhase.RESTORING && state.totalBytes.inWholeBytes > 0) {
val progressPercent = (state.progress * 100).toInt()
Text(
text = stringResource(R.string.RestoreLocalBackupActivity__s_of_s_d_percent, state.bytesRead.toUnitString(), state.totalBytes.toUnitString(), progressPercent),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp)
)
}
}
}
}
}
@DayNightPreviews
@Composable
private fun RestoreLocalBackupScreenPreview() {
Previews.Preview {
RestoreLocalBackupScreen(state = RestoreLocalBackupScreenState())
}
}

View File

@@ -0,0 +1,150 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.restore.local
import android.net.Uri
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.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import org.signal.core.util.ByteSize
import org.signal.core.util.Result
import org.signal.core.util.bytes
import org.signal.core.util.logging.Log
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.RestoreV2Event
import org.thoughtcrime.securesms.backup.v2.local.ArchiveFileSystem
import org.thoughtcrime.securesms.backup.v2.local.LocalArchiver
import org.thoughtcrime.securesms.backup.v2.local.SnapshotFileSystem
import org.thoughtcrime.securesms.database.model.databaseprotos.RestoreDecisionState
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.LocalBackupJob
import org.thoughtcrime.securesms.jobs.RestoreLocalAttachmentJob
import org.thoughtcrime.securesms.keyvalue.Completed
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.registration.ui.restore.StorageServiceRestore
import org.thoughtcrime.securesms.registration.util.RegistrationUtil
class RestoreLocalBackupActivityViewModel : ViewModel() {
companion object {
private val TAG = Log.tag(RestoreLocalBackupActivityViewModel::class)
}
private val internalState = MutableStateFlow(RestoreLocalBackupScreenState())
val state: StateFlow<RestoreLocalBackupScreenState> = internalState
init {
EventBus.getDefault().register(this)
beginRestore()
}
override fun onCleared() {
EventBus.getDefault().unregister(this)
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun onRestoreEvent(event: RestoreV2Event) {
internalState.update {
when (event.type) {
RestoreV2Event.Type.PROGRESS_RESTORE -> it.copy(
restorePhase = RestorePhase.RESTORING,
bytesRead = event.count,
totalBytes = event.estimatedTotalCount,
progress = event.getProgress()
)
RestoreV2Event.Type.PROGRESS_DOWNLOAD -> it.copy(
restorePhase = RestorePhase.RESTORING,
bytesRead = event.count,
totalBytes = event.estimatedTotalCount,
progress = event.getProgress()
)
RestoreV2Event.Type.PROGRESS_FINALIZING -> it.copy(
restorePhase = RestorePhase.FINALIZING
)
}
}
}
private fun beginRestore() {
viewModelScope.launch(Dispatchers.IO) {
internalState.update { it.copy(restorePhase = RestorePhase.RESTORING) }
val self = Recipient.self()
val selfData = BackupRepository.SelfData(self.aci.get(), self.pni.get(), self.e164.get(), ProfileKey(self.profileKey))
val backupDirectory = SignalStore.backup.newLocalBackupsDirectory
if (backupDirectory == null) {
Log.w(TAG, "No backup directory set")
internalState.update { it.copy(restorePhase = RestorePhase.FAILED) }
return@launch
}
val archiveFileSystem = ArchiveFileSystem.fromUri(AppDependencies.application, Uri.parse(backupDirectory))
if (archiveFileSystem == null) {
Log.w(TAG, "Unable to access backup directory: $backupDirectory")
internalState.update { it.copy(restorePhase = RestorePhase.FAILED) }
return@launch
}
val selectedTimestamp = SignalStore.backup.newLocalBackupsSelectedSnapshotTimestamp
val snapshots = archiveFileSystem.listSnapshots()
val snapshotInfo = snapshots.firstOrNull { it.timestamp == selectedTimestamp } ?: snapshots.firstOrNull()
if (snapshotInfo == null) {
Log.w(TAG, "No snapshots found in backup directory")
internalState.update { it.copy(restorePhase = RestorePhase.FAILED) }
return@launch
}
val snapshotFileSystem = SnapshotFileSystem(AppDependencies.application, snapshotInfo.file)
val result = LocalArchiver.import(snapshotFileSystem, selfData)
if (result is Result.Success) {
Log.i(TAG, "Local backup import succeeded")
val mediaNameToFileInfo = archiveFileSystem.filesFileSystem.allFiles()
RestoreLocalAttachmentJob.enqueueRestoreLocalAttachmentsJobs(mediaNameToFileInfo)
SignalStore.registration.restoreDecisionState = RestoreDecisionState.Completed
SignalStore.backup.backupSecretRestoreRequired = false
SignalStore.backup.newLocalBackupsSelectedSnapshotTimestamp = -1L
SignalStore.backup.newLocalBackupsEnabled = true
LocalBackupJob.enqueueArchive(false)
StorageServiceRestore.restore()
RegistrationUtil.maybeMarkRegistrationComplete()
internalState.update { it.copy(restorePhase = RestorePhase.COMPLETE) }
} else {
Log.w(TAG, "Local backup import failed")
internalState.update { it.copy(restorePhase = RestorePhase.FAILED) }
}
}
}
}
data class RestoreLocalBackupScreenState(
val restorePhase: RestorePhase = RestorePhase.RESTORING,
val bytesRead: ByteSize = 0L.bytes,
val totalBytes: ByteSize = 0L.bytes,
val progress: Float = 0f
)
enum class RestorePhase {
RESTORING,
FINALIZING,
COMPLETE,
FAILED
}

View File

@@ -0,0 +1,49 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.restore.local
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import org.signal.core.ui.compose.Dialogs
import org.thoughtcrime.securesms.R
enum class RestoreLocalBackupDialog {
FAILED_TO_LOAD_ARCHIVE,
SKIP_RESTORE_WARNING
}
@Composable
fun RestoreLocalBackupDialogDisplay(
dialog: RestoreLocalBackupDialog?,
onDialogConfirmed: (RestoreLocalBackupDialog) -> Unit,
onDismiss: () -> Unit
) {
when (dialog) {
RestoreLocalBackupDialog.FAILED_TO_LOAD_ARCHIVE -> {
Dialogs.SimpleMessageDialog(
message = stringResource(R.string.RestoreLocalBackupDialog__failed_to_load_archive),
onDismiss = onDismiss,
dismiss = stringResource(R.string.RestoreLocalBackupDialog__ok)
)
}
RestoreLocalBackupDialog.SKIP_RESTORE_WARNING -> {
Dialogs.SimpleAlertDialog(
title = "Skip restore?",
body = "If you skip restore now you will not be able to restore later. If you re-enable backups after skipping restore, your current backup will be replaced with your new messaging history.",
confirm = "Skip restore",
confirmColor = MaterialTheme.colorScheme.error,
onConfirm = {
onDialogConfirmed(RestoreLocalBackupDialog.SKIP_RESTORE_WARNING)
},
dismiss = stringResource(android.R.string.cancel)
)
}
null -> return
}
}

View File

@@ -0,0 +1,165 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.restore.local
import android.app.Activity
import android.content.Context
import android.net.Uri
import android.os.Bundle
import android.view.View
import androidx.activity.compose.LocalActivity
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.fragment.findNavController
import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.ComposeFragment
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.registration.ui.RegistrationViewModel
import org.thoughtcrime.securesms.registration.ui.phonenumber.EnterPhoneNumberMode
import org.thoughtcrime.securesms.registration.ui.restore.EnterBackupKeyViewModel
import org.thoughtcrime.securesms.restore.RestoreActivity
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
* Restore an on-device backup during registration
*/
class RestoreLocalBackupFragment : ComposeFragment() {
companion object {
private val TAG = Log.tag(RestoreLocalBackupFragment::class)
private const val LEARN_MORE_URL = "https://support.signal.org/hc/articles/360007059752"
}
private val sharedViewModel by activityViewModels<RegistrationViewModel>()
private val enterBackupKeyViewModel by viewModels<EnterBackupKeyViewModel>()
private lateinit var restoreLocalBackupViewModel: RestoreLocalBackupViewModel
private val localBackupRestore = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
when (val resultCode = result.resultCode) {
Activity.RESULT_OK -> {
sharedViewModel.onBackupSuccessfullyRestored()
findNavController().safeNavigate(RestoreLocalBackupFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.RESTART_AFTER_COLLECTION))
}
Activity.RESULT_CANCELED -> {
Log.w(TAG, "Backup restoration canceled.")
}
else -> Log.w(TAG, "Backup restoration activity ended with unknown result code: $resultCode")
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
sharedViewModel
.state
.map { it.registerAccountError }
.filterNotNull()
.collect {
sharedViewModel.registerAccountErrorShown()
enterBackupKeyViewModel.handleRegistrationFailure(it)
}
}
}
}
@Composable
override fun FragmentContent() {
val viewModel = viewModel<RestoreLocalBackupViewModel>()
restoreLocalBackupViewModel = viewModel
val state by viewModel.state.collectAsStateWithLifecycle()
val registrationState by sharedViewModel.state.collectAsStateWithLifecycle()
val enterBackupKeyState by enterBackupKeyViewModel.state.collectAsStateWithLifecycle()
SignalTheme {
val activity = LocalActivity.current as FragmentActivity
CompositionLocalProvider(LocalNavigationEventDispatcherOwner provides activity) {
RestoreLocalBackupNavDisplay(
state = state,
callback = remember { RestoreBackupCallback() },
isRegistrationInProgress = registrationState.inProgress,
enterBackupKeyState = enterBackupKeyState,
backupKey = enterBackupKeyViewModel.backupKey
)
}
}
}
private inner class RestoreBackupCallback : RestoreLocalBackupCallback {
override fun setSelectedBackup(backup: SelectableBackup) {
restoreLocalBackupViewModel.setSelectedBackup(backup)
}
override fun setSelectedBackupDirectory(context: Context, uri: Uri): Boolean {
return restoreLocalBackupViewModel.setSelectedBackupDirectory(context, uri)
}
override fun displaySkipRestoreWarning() {
restoreLocalBackupViewModel.displaySkipRestoreWarning()
}
override fun clearDialog() {
restoreLocalBackupViewModel.clearDialog()
}
override fun skipRestore() {
sharedViewModel.skipRestore()
findNavController().safeNavigate(RestoreLocalBackupFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.RESTART_AFTER_COLLECTION))
}
override fun routeToLegacyBackupRestoration(uri: Uri) {
sharedViewModel.intendToRestore(hasOldDevice = false, fromRemote = false, fromLocalV2 = false)
localBackupRestore.launch(RestoreActivity.getLocalRestoreIntent(requireContext(), uri))
}
override fun submitBackupKey() {
enterBackupKeyViewModel.registering()
val selectedTimestamp = restoreLocalBackupViewModel.state.value.selectedBackup?.timestamp ?: -1L
SignalStore.backup.newLocalBackupsSelectedSnapshotTimestamp = selectedTimestamp
sharedViewModel.registerWithBackupKey(
context = requireContext(),
backupKey = enterBackupKeyViewModel.backupKey,
e164 = null,
pin = null,
aciIdentityKeyPair = null,
pniIdentityKeyPair = null
)
}
override fun onBackupKeyChanged(key: String) {
enterBackupKeyViewModel.updateBackupKey(key)
}
override fun clearRegistrationError() {
enterBackupKeyViewModel.clearRegistrationError()
}
override fun onBackupKeyHelp() {
CommunicationActions.openBrowserLink(requireContext(), LEARN_MORE_URL)
}
}
}

View File

@@ -0,0 +1,198 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.restore.local
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.persistentListOf
import org.signal.core.ui.compose.Launchers
import org.signal.core.ui.contracts.OpenDocumentContract
import org.signal.core.ui.navigation.BottomSheetSceneStrategy
import org.signal.core.ui.navigation.LocalBottomSheetDismiss
import org.thoughtcrime.securesms.registration.ui.restore.EnterBackupKeyViewModel
/**
* Handles the restoration flow for V2 backups. Can also launch into V1 backup flow if needed.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RestoreLocalBackupNavDisplay(
state: RestoreLocalBackupState,
callback: RestoreLocalBackupCallback,
isRegistrationInProgress: Boolean,
enterBackupKeyState: EnterBackupKeyViewModel.EnterBackupKeyState,
backupKey: String
) {
val backstack = rememberNavBackStack(RestoreLocalBackupNavKey.SelectLocalBackupTypeScreen)
val bottomSheetStrategy = remember { BottomSheetSceneStrategy<NavKey>() }
val backPressedDispatcher = LocalOnBackPressedDispatcherOwner.current
val context = LocalContext.current
val folderLauncher = Launchers.rememberOpenDocumentTreeLauncher {
if (it != null) {
val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
context.contentResolver.takePersistableUriPermission(it, takeFlags)
if (callback.setSelectedBackupDirectory(context, it)) {
backstack.add(RestoreLocalBackupNavKey.SelectLocalBackupScreen)
}
}
}
val fileLauncher = Launchers.rememberOpenDocumentLauncher {
if (it != null) {
callback.routeToLegacyBackupRestoration(it)
}
}
NavDisplay(
backStack = backstack,
sceneStrategy = bottomSheetStrategy,
entryProvider = entryProvider {
entry<RestoreLocalBackupNavKey.SelectLocalBackupTypeScreen> {
SelectLocalBackupTypeScreen(
onSelectBackupFolderClick = {
backstack.add(RestoreLocalBackupNavKey.FolderInstructionSheet)
},
onSelectBackupFileClick = {
backstack.add(RestoreLocalBackupNavKey.FileInstructionSheet)
},
onCancelClick = {
backPressedDispatcher?.onBackPressedDispatcher?.onBackPressed()
}
)
}
entry<RestoreLocalBackupNavKey.FileInstructionSheet>(
metadata = BottomSheetSceneStrategy.bottomSheet()
) {
SelectYourBackupFileSheetContent(onContinueClick = {
fileLauncher.launch(OpenDocumentContract.Input())
})
}
entry<RestoreLocalBackupNavKey.FolderInstructionSheet>(
metadata = BottomSheetSceneStrategy.bottomSheet()
) {
SelectYourBackupFolderSheetContent(onContinueClick = {
folderLauncher.launch(null)
})
}
entry<RestoreLocalBackupNavKey.SelectLocalBackupScreen> {
SelectLocalBackupScreen(
selectedBackup = requireNotNull(state.selectedBackup) { "No chosen backup." },
isSelectedBackupLatest = state.selectedBackup == state.selectableBackups.firstOrNull(),
onRestoreBackupClick = {
backstack.add(RestoreLocalBackupNavKey.EnterLocalBackupKeyScreen)
},
onCancelClick = {
backPressedDispatcher?.onBackPressedDispatcher?.onBackPressed()
},
onChooseADifferentBackupClick = {
backstack.add(RestoreLocalBackupNavKey.SelectLocalBackupSheet)
}
)
}
entry<RestoreLocalBackupNavKey.SelectLocalBackupSheet>(
metadata = BottomSheetSceneStrategy.bottomSheet()
) {
val dismissSheet = LocalBottomSheetDismiss.current
SelectLocalBackupSheetContent(
selectedBackup = requireNotNull(state.selectedBackup) { "No chosen backup." },
selectableBackups = state.selectableBackups,
onBackupSelected = {
callback.setSelectedBackup(it)
dismissSheet()
}
)
}
entry<RestoreLocalBackupNavKey.EnterLocalBackupKeyScreen> {
EnterLocalBackupKeyScreen(
backupKey = backupKey,
isRegistrationInProgress = isRegistrationInProgress,
isBackupKeyValid = enterBackupKeyState.backupKeyValid,
aepValidationError = enterBackupKeyState.aepValidationError,
onBackupKeyChanged = callback::onBackupKeyChanged,
onNextClicked = callback::submitBackupKey,
onNoBackupKeyClick = {
backstack.add(RestoreLocalBackupNavKey.NoRecoveryKeySheet)
},
showRegistrationError = enterBackupKeyState.showRegistrationError,
registerAccountResult = enterBackupKeyState.registerAccountResult,
onRegistrationErrorDismiss = callback::clearRegistrationError,
onBackupKeyHelp = callback::onBackupKeyHelp
)
}
entry<RestoreLocalBackupNavKey.NoRecoveryKeySheet>(
metadata = BottomSheetSceneStrategy.bottomSheet()
) {
val dismissSheet = LocalBottomSheetDismiss.current
NoRecoveryKeySheetContent(
onSkipAndDontRestoreClick = {
dismissSheet()
callback.displaySkipRestoreWarning()
},
onLearnMoreClick = {
// TODO
}
)
}
}
)
RestoreLocalBackupDialogDisplay(state.dialog, {
if (it == RestoreLocalBackupDialog.SKIP_RESTORE_WARNING) {
callback.skipRestore()
}
}, callback::clearDialog)
}
data class RestoreLocalBackupState(
val dialog: RestoreLocalBackupDialog? = null,
val selectedBackup: SelectableBackup? = null,
val selectableBackups: PersistentList<SelectableBackup> = persistentListOf()
)
interface RestoreLocalBackupCallback {
fun setSelectedBackup(backup: SelectableBackup)
fun setSelectedBackupDirectory(context: Context, uri: Uri): Boolean
fun displaySkipRestoreWarning()
fun clearDialog()
fun skipRestore()
fun submitBackupKey()
fun routeToLegacyBackupRestoration(uri: Uri)
fun onBackupKeyChanged(key: String)
fun clearRegistrationError()
fun onBackupKeyHelp()
object Empty : RestoreLocalBackupCallback {
override fun setSelectedBackup(backup: SelectableBackup) = Unit
override fun setSelectedBackupDirectory(context: Context, uri: Uri) = false
override fun displaySkipRestoreWarning() = Unit
override fun clearDialog() = Unit
override fun skipRestore() = Unit
override fun submitBackupKey() = Unit
override fun routeToLegacyBackupRestoration(uri: Uri) = Unit
override fun onBackupKeyChanged(key: String) = Unit
override fun clearRegistrationError() = Unit
override fun onBackupKeyHelp() = Unit
}
}

View File

@@ -0,0 +1,32 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.restore.local
import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable
sealed interface RestoreLocalBackupNavKey : NavKey {
@Serializable
object SelectLocalBackupTypeScreen : RestoreLocalBackupNavKey
@Serializable
object FolderInstructionSheet : RestoreLocalBackupNavKey
@Serializable
object FileInstructionSheet : RestoreLocalBackupNavKey
@Serializable
object SelectLocalBackupScreen : RestoreLocalBackupNavKey
@Serializable
object SelectLocalBackupSheet : RestoreLocalBackupNavKey
@Serializable
object EnterLocalBackupKeyScreen : RestoreLocalBackupNavKey
@Serializable
object NoRecoveryKeySheet : RestoreLocalBackupNavKey
}

View File

@@ -0,0 +1,85 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.restore.local
import android.content.Context
import android.net.Uri
import androidx.lifecycle.ViewModel
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import org.signal.core.util.bytes
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.local.ArchiveFileSystem
import org.thoughtcrime.securesms.backup.v2.local.SnapshotFileSystem
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.DateUtils
import java.util.Locale
class RestoreLocalBackupViewModel : ViewModel() {
companion object {
private val TAG = Log.tag(RestoreLocalBackupViewModel::class.java)
}
private val internalState = MutableStateFlow(RestoreLocalBackupState())
val state: StateFlow<RestoreLocalBackupState> = internalState
fun setSelectedBackup(backup: SelectableBackup) {
internalState.update { it.copy(selectedBackup = backup) }
}
fun setSelectedBackupDirectory(context: Context, uri: Uri): Boolean {
SignalStore.backup.newLocalBackupsDirectory = uri.toString()
val archiveFileSystem = ArchiveFileSystem.fromUri(context, uri)
if (archiveFileSystem == null) {
Log.w(TAG, "Unable to access backup directory: $uri")
internalState.update { it.copy(selectedBackup = null, selectableBackups = persistentListOf(), dialog = RestoreLocalBackupDialog.FAILED_TO_LOAD_ARCHIVE) }
return false
}
val selectableBackups = archiveFileSystem
.listSnapshots()
.take(2)
.map { snapshot ->
val dateLabel = if (DateUtils.isSameDay(System.currentTimeMillis(), snapshot.timestamp)) {
context.getString(R.string.DateUtils_today)
} else {
DateUtils.formatDateWithYear(Locale.getDefault(), snapshot.timestamp)
}
val timeLabel = DateUtils.getOnlyTimeString(context, snapshot.timestamp)
val sizeBytes = SnapshotFileSystem(context, snapshot.file).mainLength() ?: 0L
SelectableBackup(
timestamp = snapshot.timestamp,
backupTime = "$dateLabel$timeLabel",
backupSize = sizeBytes.bytes.toUnitString()
)
}
.toPersistentList()
internalState.update {
it.copy(
selectableBackups = selectableBackups,
selectedBackup = selectableBackups.firstOrNull()
)
}
return true
}
fun displaySkipRestoreWarning() {
internalState.update { it.copy(dialog = RestoreLocalBackupDialog.SKIP_RESTORE_WARNING) }
}
fun clearDialog() {
internalState.update { it.copy(dialog = null) }
}
}

View File

@@ -0,0 +1,164 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.restore.local
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
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.thoughtcrime.securesms.R
@Composable
fun SelectYourBackupFolderSheetContent(
onContinueClick: () -> Unit
) {
Column {
SelectInstructionsSheetContent(
title = stringResource(R.string.SelectInstructionsSheet__select_your_backup_folder),
onContinueClick = onContinueClick
) {
InstructionRow(
icon = ImageVector.vectorResource(R.drawable.ic_tap_outline_24),
text = stringResource(R.string.SelectInstructionsSheet__tap_select_this_folder)
)
InstructionRow(
icon = ImageVector.vectorResource(R.drawable.symbol_error_circle_24),
text = stringResource(R.string.SelectInstructionsSheet__do_not_select_individual_files)
)
}
}
}
@Composable
fun SelectYourBackupFileSheetContent(
onContinueClick: () -> Unit
) {
Column {
SelectInstructionsSheetContent(
title = stringResource(R.string.SelectInstructionsSheet__select_your_backup_file),
onContinueClick = onContinueClick
) {
InstructionRow(
icon = ImageVector.vectorResource(R.drawable.ic_tap_outline_24),
text = stringResource(R.string.SelectInstructionsSheet__tap_on_the_backup_file)
)
}
}
}
@Composable
private fun ColumnScope.SelectInstructionsSheetContent(
title: String,
onContinueClick: () -> Unit,
instructionRows: @Composable ColumnScope.() -> Unit
) {
Text(
text = title,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier
.align(alignment = Alignment.CenterHorizontally)
.padding(bottom = 8.dp, top = 38.dp)
.padding(horizontal = 40.dp)
)
Text(
text = stringResource(R.string.SelectInstructionsSheet__after_tapping_continue),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.align(alignment = Alignment.CenterHorizontally)
.padding(bottom = 36.dp)
.padding(horizontal = 40.dp)
)
Column(verticalArrangement = spacedBy(24.dp)) {
InstructionRow(
icon = ImageVector.vectorResource(R.drawable.symbol_folder_24),
text = stringResource(R.string.SelectInstructionsSheet__select_the_top_level_folder)
)
instructionRows()
}
Buttons.LargeTonal(
onClick = onContinueClick,
modifier = Modifier
.widthIn(min = 220.dp)
.padding(vertical = 56.dp)
.align(alignment = Alignment.CenterHorizontally)
) {
Text(text = stringResource(R.string.SelectInstructionsSheet__continue_button))
}
}
@Composable
private fun InstructionRow(
icon: ImageVector,
text: String,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.padding(horizontal = 64.dp)
.fillMaxWidth()
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(end = 24.dp)
)
Text(
text = text,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@DayNightPreviews
@Composable
private fun SelectYourBackupFolderSheetContentPreview() {
Previews.BottomSheetPreview {
Column {
SelectYourBackupFolderSheetContent(
onContinueClick = {}
)
}
}
}
@DayNightPreviews
@Composable
private fun SelectYourBackupFileSheetContentPreview() {
Previews.BottomSheetPreview {
Column {
SelectYourBackupFileSheetContent(
onContinueClick = {}
)
}
}
}

View File

@@ -0,0 +1,163 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.restore.local
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
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.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
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.SignalIcons
import org.signal.core.ui.compose.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.registration.ui.shared.RegistrationScreen
/**
* Allows the user to select a specific on-device backup to restore.
*/
@Composable
fun SelectLocalBackupScreen(
selectedBackup: SelectableBackup,
isSelectedBackupLatest: Boolean,
onRestoreBackupClick: () -> Unit,
onCancelClick: () -> Unit,
onChooseADifferentBackupClick: () -> Unit
) {
RegistrationScreen(
title = stringResource(R.string.SelectLocalBackupScreen__restore_on_device_backup),
subtitle = stringResource(R.string.SelectLocalBackupScreen__restore_your_messages_from_the_backup_folder),
bottomContent = {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Buttons.LargeTonal(
onClick = onRestoreBackupClick,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(R.string.SelectLocalBackupScreen__restore_backup)
)
}
TextButton(
onClick = onCancelClick,
modifier = Modifier.padding(top = 24.dp)
) {
Text(stringResource(android.R.string.cancel))
}
}
}
) {
YourBackupCard(
selectedBackup = selectedBackup,
isSelectedBackupLatest = isSelectedBackupLatest
)
TextButton(
onClick = onChooseADifferentBackupClick,
modifier = Modifier
.padding(top = 28.dp)
.align(alignment = Alignment.CenterHorizontally)
) {
Icon(
imageVector = SignalIcons.Backup.imageVector,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp)
)
if (isSelectedBackupLatest) {
Text(text = stringResource(R.string.SelectLocalBackupScreen__choose_an_earlier_backup))
} else {
Text(text = stringResource(R.string.SelectLocalBackupScreen__choose_a_different_backup))
}
}
}
}
@Composable
fun YourBackupCard(
selectedBackup: SelectableBackup,
isSelectedBackupLatest: Boolean,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.fillMaxWidth()
.background(color = SignalTheme.colors.colorSurface2, shape = RoundedCornerShape(12.dp))
.padding(20.dp)
) {
Text(
text = if (isSelectedBackupLatest) {
stringResource(R.string.SelectLocalBackupScreen__your_latest_backup)
} else {
stringResource(R.string.SelectLocalBackupScreen__your_backup)
},
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 16.dp)
)
BackupInfoRow(
icon = ImageVector.vectorResource(R.drawable.symbol_recent_24),
text = selectedBackup.backupTime
)
BackupInfoRow(
icon = ImageVector.vectorResource(R.drawable.symbol_file_24),
text = selectedBackup.backupSize
)
}
}
@Composable
fun BackupInfoRow(
icon: ImageVector,
text: String
) {
Row {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Text(
text = text,
modifier = Modifier.padding(start = 12.dp, bottom = 12.dp)
)
}
}
@DayNightPreviews
@Composable
fun SelectLocalBackupScreenPreview() {
Previews.Preview {
SelectLocalBackupScreen(
selectedBackup = SelectableBackup(
timestamp = 0L,
backupTime = "Today \u2022 12:34 PM",
backupSize = "1.38 GB"
),
isSelectedBackupLatest = true,
onRestoreBackupClick = {},
onCancelClick = {},
onChooseADifferentBackupClick = {}
)
}
}

View File

@@ -0,0 +1,131 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.restore.local
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.persistentListOf
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.Rows
import org.signal.core.ui.compose.horizontalGutters
import org.thoughtcrime.securesms.R
@Composable
fun SelectLocalBackupSheetContent(
selectedBackup: SelectableBackup,
selectableBackups: PersistentList<SelectableBackup>,
onBackupSelected: (SelectableBackup) -> Unit
) {
var selection by remember { mutableStateOf(selectedBackup) }
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = stringResource(R.string.SelectLocalBackupSheet__choose_a_backup_to_restore),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier = Modifier
.padding(horizontal = 32.dp)
.padding(bottom = 6.dp, top = 20.dp)
)
Text(
text = stringResource(R.string.SelectLocalBackupSheet__choosing_an_older_backup),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.padding(horizontal = 32.dp)
.padding(bottom = 38.dp)
)
val backups = remember(selectableBackups) {
selectableBackups.take(2)
}
backups.forEachIndexed { idx, backup ->
val shape = if (idx == 0) {
RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
} else {
RoundedCornerShape(bottomStart = 18.dp, bottomEnd = 18.dp)
}
Rows.RadioRow(
text = backup.backupTime,
label = backup.backupSize,
selected = selection == backup,
modifier = Modifier
.horizontalGutters()
.clip(shape)
.background(
color = MaterialTheme.colorScheme.surface,
shape = shape
)
.clickable(onClick = {
selection = backup
})
)
}
Buttons.LargeTonal(
onClick = { onBackupSelected(selection) },
enabled = selectedBackup != selection,
modifier = Modifier
.padding(vertical = 56.dp)
.widthIn(min = 220.dp)
) {
Text(text = stringResource(R.string.SelectLocalBackupSheet__continue_button))
}
}
}
@DayNightPreviews
@Composable
private fun SelectLocalBackupSheetContentPreview() {
Previews.BottomSheetPreview {
SelectLocalBackupSheetContent(
selectedBackup = SelectableBackup(
timestamp = 0L,
backupTime = "Today \u2022 3:38am",
backupSize = "1.38 GB"
),
selectableBackups = persistentListOf(
SelectableBackup(
timestamp = 0L,
backupTime = "Today \u2022 3:38am",
backupSize = "1.38 GB"
),
SelectableBackup(
timestamp = 1L,
backupTime = "August 13, 2024 \u2022 3:21am",
backupSize = "1.34 GB"
)
),
onBackupSelected = {}
)
}
}

View File

@@ -0,0 +1,119 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.restore.local
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
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.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.registration.ui.shared.RegistrationScreen
/**
* User can select either a folder-based or single backup file for restoration during registration.
*/
@Composable
fun SelectLocalBackupTypeScreen(
onSelectBackupFolderClick: () -> Unit,
onSelectBackupFileClick: () -> Unit,
onCancelClick: () -> Unit
) {
RegistrationScreen(
title = stringResource(R.string.SelectLocalBackupTypeScreen__restore_on_device_backup),
subtitle = stringResource(R.string.SelectLocalBackupTypeScreen__restore_your_messages_from_the_backup),
bottomContent = {
TextButton(onClick = onCancelClick, modifier = Modifier.align(Alignment.Center)) {
Text(stringResource(android.R.string.cancel))
}
}
) {
ChooseBackupFolderCard(
onClick = onSelectBackupFolderClick,
modifier = Modifier.align(Alignment.CenterHorizontally)
)
TextButton(
onClick = onSelectBackupFileClick,
modifier = Modifier
.padding(top = 24.dp)
.align(Alignment.CenterHorizontally)
) {
Text(
text = stringResource(R.string.SelectLocalBackupTypeScreen__i_saved_my_backup_as_a_single_file)
)
}
}
}
@Composable
private fun ChooseBackupFolderCard(onClick: () -> Unit, modifier: Modifier = Modifier) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.background(color = SignalTheme.colors.colorSurface2, shape = RoundedCornerShape(12.dp))
.clickable(onClick = onClick, role = Role.Button)
.padding(end = 24.dp)
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.symbol_folder_24),
tint = MaterialTheme.colorScheme.primary,
contentDescription = null,
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 21.dp)
.size(40.dp)
)
Column {
Text(
text = stringResource(R.string.SelectLocalBackupTypeScreen__choose_backup_folder)
)
Text(
text = stringResource(R.string.SelectLocalBackupTypeScreen__select_the_folder_on_your_device),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@DayNightPreviews
@Composable
private fun SelectLocalBackupTypeScreenPreview() {
Previews.Preview {
SelectLocalBackupTypeScreen(
onSelectBackupFolderClick = {},
onSelectBackupFileClick = {},
onCancelClick = {}
)
}
}
@DayNightPreviews
@Composable
private fun ChooseBackupFolderCardPreview() {
Previews.Preview {
ChooseBackupFolderCard(onClick = {})
}
}

View File

@@ -0,0 +1,12 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.restore.local
data class SelectableBackup(
val timestamp: Long,
val backupTime: String,
val backupSize: String
)

View File

@@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.restore
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.activity.OnBackPressedCallback
import androidx.activity.viewModels
@@ -24,7 +25,6 @@ import org.signal.core.util.ThreadUtil
import org.signal.core.util.getParcelableExtraCompat
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.BaseActivity
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.PassphraseRequiredActivity
import org.thoughtcrime.securesms.R
@@ -33,7 +33,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.isWantingManualRemoteRestore
import org.thoughtcrime.securesms.keyvalue.isWantingNewLocalBackupRestore
import org.thoughtcrime.securesms.registration.ui.restore.RemoteRestoreActivity
import org.thoughtcrime.securesms.registration.ui.restore.local.InternalNewLocalRestoreActivity
import org.thoughtcrime.securesms.registration.ui.restore.local.RestoreLocalBackupActivity
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.TextSecurePreferences
@@ -83,8 +83,8 @@ class RestoreActivity : BaseActivity() {
if (SignalStore.registration.restoreDecisionState.isWantingManualRemoteRestore) {
Log.i(TAG, "User has no available restore methods but previously wanted a remote restore, navigating immediately.")
startActivity(RemoteRestoreActivity.getIntent(this, isOnlyOption = true))
} else if (SignalStore.registration.restoreDecisionState.isWantingNewLocalBackupRestore && (BuildConfig.DEBUG || Environment.IS_NIGHTLY)) {
startActivity(InternalNewLocalRestoreActivity.getIntent(this))
} else if (SignalStore.registration.restoreDecisionState.isWantingNewLocalBackupRestore && Environment.Backups.isNewFormatSupportedForLocalBackup()) {
startActivity(RestoreLocalBackupActivity.getIntent(this))
} else {
Log.i(TAG, "No restore methods available, skipping")
sharedViewModel.skipRestore()
@@ -103,7 +103,14 @@ class RestoreActivity : BaseActivity() {
}
}
NavTarget.LOCAL_RESTORE -> navController.safeNavigate(RestoreDirections.goDirectlyToChooseLocalBackup())
NavTarget.LOCAL_RESTORE -> {
if (intent.data != null) {
sharedViewModel.setBackupFileUri(intent.data!!)
navController.safeNavigate(RestoreDirections.goDirectlyToRestoreLocalBackup())
} else {
navController.safeNavigate(RestoreDirections.goDirectlyToChooseLocalBackup())
}
}
NavTarget.TRANSFER -> navController.safeNavigate(RestoreDirections.goDirectlyToDeviceTransfer())
}
@@ -185,9 +192,11 @@ class RestoreActivity : BaseActivity() {
private const val EXTRA_NAV_TARGET = "nav_target"
@JvmStatic
fun getLocalRestoreIntent(context: Context): Intent {
@JvmOverloads
fun getLocalRestoreIntent(context: Context, uri: Uri? = null): Intent {
return Intent(context, RestoreActivity::class.java).apply {
putExtra(EXTRA_NAV_TARGET, NavTarget.LOCAL_RESTORE.value)
setData(uri)
}
}

View File

@@ -18,6 +18,11 @@ object Environment {
fun supportsGooglePlayBilling(): Boolean {
return BuildConfig.APPLICATION_ID == GOOGLE_PLAY_BILLING_APPLICATION_ID
}
@JvmStatic
fun isNewFormatSupportedForLocalBackup(): Boolean {
return BuildConfig.DEBUG || IS_NIGHTLY
}
}
object Donations {

View File

@@ -1237,17 +1237,6 @@ object RemoteConfig {
hotSwappable = true
)
/**
* Whether or not the new UX for unified local backups is enabled
*/
@JvmStatic
@get:JvmName("unifiedLocalBackups")
val unifiedLocalBackups: Boolean by remoteBoolean(
key = "android.unifiedLocalBackups",
defaultValue = false,
hotSwappable = true
)
/**
* Whether to receive and display group member labels.
*/