diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d739e537d6..e31c4f5c6d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -752,7 +752,7 @@ android:theme="@style/Signal.DayNight.NoActionBar" /> diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsActivity.kt index 4b659cbc6f..eb241ea0b6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsActivity.kt @@ -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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupsSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupsSettingsFragment.kt index 5fb7bd714a..0a2a19c143 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupsSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupsSettingsFragment.kt @@ -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 - ) - } - } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupsSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupsSettingsState.kt index bd834f3f3b..fb79fd6bd1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupsSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupsSettingsState.kt @@ -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 ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupsSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupsSettingsViewModel.kt index f13dc40be6..18d8404231 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupsSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupsSettingsViewModel.kt @@ -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 ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/InternalNewLocalBackupCreateFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/InternalNewLocalBackupCreateFragment.kt deleted file mode 100644 index 2abfebbf5e..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/InternalNewLocalBackupCreateFragment.kt +++ /dev/null @@ -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 - - 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 - ) - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsFragment.kt index 6489c324d5..8093789646 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsFragment.kt @@ -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): ActivityResultLauncher { +private fun rememberChooseBackupLocationLauncher(backStack: NavBackStack): ActivityResultLauncher { 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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsSettingsCallback.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsSettingsCallback.kt index dacd03ae88..0625a16e54 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsSettingsCallback.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsSettingsCallback.kt @@ -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, + private val chooseBackupLocationLauncher: ActivityResultLauncher, 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() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsViewModel.kt index 892c91ae17..883607e442 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsViewModel.kt @@ -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)) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundFragment.kt index 2fedfb0ebb..06874c75b0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundFragment.kt @@ -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() }, diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt index af3ace0f77..d087a64ab4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt @@ -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 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. diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java index 14758a6f52..409f4ed691 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/BackupsPreferenceFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/BackupsPreferenceFragment.java index 66095f68d7..294429a29a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/BackupsPreferenceFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/BackupsPreferenceFragment.java @@ -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() diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/phonenumber/EnterPhoneNumberFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/phonenumber/EnterPhoneNumberFragment.kt index 79a74f0540..21fa83b029 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/phonenumber/EnterPhoneNumberFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/phonenumber/EnterPhoneNumberFragment.kt @@ -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()) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/EnterBackupKeyFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/EnterBackupKeyFragment.kt index 2bb55b9507..89844b1533 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/EnterBackupKeyFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/EnterBackupKeyFragment.kt @@ -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 + ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/RegistrationErrorDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/RegistrationErrorDialogs.kt new file mode 100644 index 0000000000..57df6d9e98 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/RegistrationErrorDialogs.kt @@ -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) + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/RestoreMethod.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/RestoreMethod.kt index ad0e7291aa..735e66572a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/RestoreMethod.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/RestoreMethod.kt @@ -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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/RestoreRow.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/RestoreRow.kt index 60033b658e..f1920a9255 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/RestoreRow.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/RestoreRow.kt @@ -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) ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/SelectManualRestoreMethodFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/SelectManualRestoreMethodFragment.kt index df09fc83db..c6e898ffad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/SelectManualRestoreMethodFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/SelectManualRestoreMethodFragment.kt @@ -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) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/EnterLocalBackupKeyScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/EnterLocalBackupKeyScreen.kt new file mode 100644 index 0000000000..3d8597401f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/EnterLocalBackupKeyScreen.kt @@ -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 = {} + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/InternalNewLocalBackupRestore.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/InternalNewLocalBackupRestore.kt deleted file mode 100644 index 75997464d7..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/InternalNewLocalBackupRestore.kt +++ /dev/null @@ -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() - - private lateinit var chooseBackupLocationLauncher: ActivityResultLauncher - 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(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 - ) - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/InternalNewLocalRestoreActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/InternalNewLocalRestoreActivity.kt deleted file mode 100644 index f555d7db04..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/InternalNewLocalRestoreActivity.kt +++ /dev/null @@ -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("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...") - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/NoRecoveryKeySheetContent.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/NoRecoveryKeySheetContent.kt new file mode 100644 index 0000000000..474446cd1e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/NoRecoveryKeySheetContent.kt @@ -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 can’t be recovered without their 64-character recovery key. If you’ve lost your recovery key Signal can’t 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 = {} + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/RestoreLocalBackupActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/RestoreLocalBackupActivity.kt new file mode 100644 index 0000000000..bf685ef58c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/RestoreLocalBackupActivity.kt @@ -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()) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/RestoreLocalBackupActivityViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/RestoreLocalBackupActivityViewModel.kt new file mode 100644 index 0000000000..4d8479ed55 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/RestoreLocalBackupActivityViewModel.kt @@ -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 = 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 +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/RestoreLocalBackupDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/RestoreLocalBackupDialog.kt new file mode 100644 index 0000000000..d72d8596a9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/RestoreLocalBackupDialog.kt @@ -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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/RestoreLocalBackupFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/RestoreLocalBackupFragment.kt new file mode 100644 index 0000000000..6c149755bc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/RestoreLocalBackupFragment.kt @@ -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() + private val enterBackupKeyViewModel by viewModels() + 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 = 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) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/RestoreLocalBackupNavDisplay.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/RestoreLocalBackupNavDisplay.kt new file mode 100644 index 0000000000..49fb885408 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/RestoreLocalBackupNavDisplay.kt @@ -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() } + 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 { + SelectLocalBackupTypeScreen( + onSelectBackupFolderClick = { + backstack.add(RestoreLocalBackupNavKey.FolderInstructionSheet) + }, + onSelectBackupFileClick = { + backstack.add(RestoreLocalBackupNavKey.FileInstructionSheet) + }, + onCancelClick = { + backPressedDispatcher?.onBackPressedDispatcher?.onBackPressed() + } + ) + } + + entry( + metadata = BottomSheetSceneStrategy.bottomSheet() + ) { + SelectYourBackupFileSheetContent(onContinueClick = { + fileLauncher.launch(OpenDocumentContract.Input()) + }) + } + + entry( + metadata = BottomSheetSceneStrategy.bottomSheet() + ) { + SelectYourBackupFolderSheetContent(onContinueClick = { + folderLauncher.launch(null) + }) + } + + entry { + 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( + metadata = BottomSheetSceneStrategy.bottomSheet() + ) { + val dismissSheet = LocalBottomSheetDismiss.current + SelectLocalBackupSheetContent( + selectedBackup = requireNotNull(state.selectedBackup) { "No chosen backup." }, + selectableBackups = state.selectableBackups, + onBackupSelected = { + callback.setSelectedBackup(it) + dismissSheet() + } + ) + } + + entry { + 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( + 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 = 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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/RestoreLocalBackupNavKey.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/RestoreLocalBackupNavKey.kt new file mode 100644 index 0000000000..7a8079cd74 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/RestoreLocalBackupNavKey.kt @@ -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 +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/RestoreLocalBackupViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/RestoreLocalBackupViewModel.kt new file mode 100644 index 0000000000..6b0d357767 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/RestoreLocalBackupViewModel.kt @@ -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 = 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) } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/SelectInstructionsSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/SelectInstructionsSheet.kt new file mode 100644 index 0000000000..ca4c6a28bf --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/SelectInstructionsSheet.kt @@ -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 = {} + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/SelectLocalBackupScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/SelectLocalBackupScreen.kt new file mode 100644 index 0000000000..eeff6fa004 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/SelectLocalBackupScreen.kt @@ -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 = {} + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/SelectLocalBackupSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/SelectLocalBackupSheet.kt new file mode 100644 index 0000000000..41065f87c2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/SelectLocalBackupSheet.kt @@ -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, + 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 = {} + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/SelectLocalBackupTypeScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/SelectLocalBackupTypeScreen.kt new file mode 100644 index 0000000000..2f9d3d3dfe --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/SelectLocalBackupTypeScreen.kt @@ -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 = {}) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/SelectableBackup.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/SelectableBackup.kt new file mode 100644 index 0000000000..f6fe4fb81b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/local/SelectableBackup.kt @@ -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 +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreActivity.kt index 768fdee03b..71f4e5032f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreActivity.kt @@ -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) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Environment.kt b/app/src/main/java/org/thoughtcrime/securesms/util/Environment.kt index acf7535563..fc1ebb0172 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/Environment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Environment.kt @@ -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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt index efa0768de9..c6c098e351 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt @@ -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. */ diff --git a/app/src/main/res/navigation/app_settings_with_change_number.xml b/app/src/main/res/navigation/app_settings_with_change_number.xml index 293967bed2..597492e399 100644 --- a/app/src/main/res/navigation/app_settings_with_change_number.xml +++ b/app/src/main/res/navigation/app_settings_with_change_number.xml @@ -1210,14 +1210,6 @@ app:popEnterAnim="@anim/fragment_close_enter" app:popExitAnim="@anim/fragment_close_exit" /> - - - - - diff --git a/app/src/main/res/navigation/registration_v3.xml b/app/src/main/res/navigation/registration_v3.xml index 587d5554cf..c6432ebabb 100644 --- a/app/src/main/res/navigation/registration_v3.xml +++ b/app/src/main/res/navigation/registration_v3.xml @@ -230,6 +230,14 @@ app:popEnterAnim="@anim/nav_default_pop_enter_anim" app:popExitAnim="@anim/nav_default_pop_exit_anim" /> + + @@ -253,8 +261,20 @@ + android:name="org.thoughtcrime.securesms.registration.ui.restore.local.InternalNewLocalBackupRestore" /> + + + + + Get your Signal account and message history onto this device. - From Signal Secure Backups + Restore Signal backup - Your free or paid Signal Backup plan - - From a backup folder + Restore your text messages and media from your Signal backup plan. + + Restore on-device backup + + Restore your messages from a backup you saved on your device. From a backup file @@ -8889,6 +8891,87 @@ Signal Android Backup restore permanent failure Android SignalBackups Import Permanent Failure + + Enter your recovery key + + Your recovery key is a 64-character code required to recover your account and data. + + No backup key? + + Next + + + Failed to load archive. Please select a different directory. + + OK + + + Backup restore failed + + Restoring backup + + Depending on the size of your backup, this could take a few minutes. + + Restoring messages… + + Finalizing… + + Restore complete + + Restore failed + + %1$s of %2$s (%3$d%%) + + + Select your backup folder + + Tap \"Select this folder\" + + Do not select individual files + + Select your backup file + + Tap on the backup file you saved to your device + + After tapping \"Continue,\" here\'s how to access your backup: + + Select the top-level folder where your backup is stored + + Continue + + + Restore on-device backup + + Restore your messages from the backup folder you saved on your device. If you don\'t restore now, you won\'t be able to restore later. + + Restore backup + + Choose an earlier backup + + Choose a different backup + + Your latest backup: + + Your backup: + + + Choose a backup to restore + + Choosing an older backup may result in lost messages or media. + + Continue + + + Restore on-device backup + + Restore your messages from the backup you saved on your device. If you don\'t restore now, you won\'t be able to restore later. + + I saved my backup as a single file + + Choose backup folder + + Select the folder on your device where your backup is stored + Signal Android Backup export network error diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/BottomSheets.kt b/core/ui/src/main/java/org/signal/core/ui/compose/BottomSheets.kt index 06a4c6586e..73dc9df8dc 100644 --- a/core/ui/src/main/java/org/signal/core/ui/compose/BottomSheets.kt +++ b/core/ui/src/main/java/org/signal/core/ui/compose/BottomSheets.kt @@ -9,12 +9,14 @@ package org.signal.core.ui.compose import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.ModalBottomSheetProperties import androidx.compose.material3.SheetState import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable @@ -29,11 +31,13 @@ object BottomSheets { fun BottomSheet( onDismissRequest: () -> Unit, sheetState: SheetState = rememberModalBottomSheetState(), - content: @Composable () -> Unit + properties: ModalBottomSheetProperties = ModalBottomSheetProperties(), + content: @Composable ColumnScope.() -> Unit ) { return ModalBottomSheet( onDismissRequest = onDismissRequest, sheetState = sheetState, + properties = properties, dragHandle = { Handle() } ) { content() diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/Launchers.kt b/core/ui/src/main/java/org/signal/core/ui/compose/Launchers.kt new file mode 100644 index 0000000000..e77ca2bd17 --- /dev/null +++ b/core/ui/src/main/java/org/signal/core/ui/compose/Launchers.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.core.ui.compose + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.ActivityResultLauncher +import androidx.compose.runtime.Composable +import org.signal.core.ui.contracts.OpenDocumentContract +import org.signal.core.ui.contracts.OpenDocumentTreeContract + +object Launchers { + + /** + * Returns a launcher for ACTION_OPEN_DOCUMENT_TREE that invokes [onResult] with the selected + * Uri, or null if the user cancels. + */ + @Composable + fun rememberOpenDocumentTreeLauncher( + onResult: (Uri?) -> Unit + ): ActivityResultLauncher { + return rememberLauncherForActivityResult(OpenDocumentTreeContract()) { uri -> + onResult(uri) + } + } + + /** + * Returns a launcher for ACTION_OPEN_DOCUMENT / ACTION_GET_CONTENT that invokes [onResult] + * with the selected Uri, or null if the user cancels. + */ + @Composable + @Suppress("unused") + fun rememberOpenDocumentLauncher( + onResult: (Uri?) -> Unit + ): ActivityResultLauncher { + return rememberLauncherForActivityResult(OpenDocumentContract()) { uri -> + onResult(uri) + } + } +} diff --git a/core/ui/src/main/java/org/signal/core/ui/contracts/OpenDocumentContract.kt b/core/ui/src/main/java/org/signal/core/ui/contracts/OpenDocumentContract.kt new file mode 100644 index 0000000000..a2f33634f6 --- /dev/null +++ b/core/ui/src/main/java/org/signal/core/ui/contracts/OpenDocumentContract.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.core.ui.contracts + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.DocumentsContract +import androidx.activity.result.contract.ActivityResultContract + +/** + * ActivityResultContract for selecting a single file. + * + * Use [Mode.DOCUMENT] (ACTION_OPEN_DOCUMENT) when you need a Uri that can be persisted via + * takePersistableUriPermission. Use [Mode.CONTENT] (ACTION_GET_CONTENT) for one-off access where + * persistence is not needed. + */ +class OpenDocumentContract : ActivityResultContract() { + + data class Input( + val mode: Mode = Mode.DOCUMENT, + val mimeTypes: List = listOf("*/*"), + val initialUri: Uri? = null + ) + + enum class Mode { + DOCUMENT, + CONTENT + } + + override fun createIntent(context: Context, input: Input): Intent { + return Intent().apply { + action = when (input.mode) { + Mode.DOCUMENT -> Intent.ACTION_OPEN_DOCUMENT + Mode.CONTENT -> Intent.ACTION_GET_CONTENT + } + + type = "*/*" + + if (input.mimeTypes.isNotEmpty()) { + putExtra(Intent.EXTRA_MIME_TYPES, input.mimeTypes.toTypedArray()) + } + + if (Build.VERSION.SDK_INT >= 26 && input.initialUri != null) { + putExtra(DocumentsContract.EXTRA_INITIAL_URI, input.initialUri) + } + + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + if (input.mode == Mode.DOCUMENT) { + addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) + } + } + } + + override fun parseResult(resultCode: Int, intent: Intent?): Uri? { + return if (resultCode == Activity.RESULT_OK) intent?.data else null + } +} diff --git a/core/ui/src/main/java/org/signal/core/ui/contracts/OpenDocumentTreeContract.kt b/core/ui/src/main/java/org/signal/core/ui/contracts/OpenDocumentTreeContract.kt new file mode 100644 index 0000000000..7deb96d4f9 --- /dev/null +++ b/core/ui/src/main/java/org/signal/core/ui/contracts/OpenDocumentTreeContract.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.core.ui.contracts + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.DocumentsContract +import androidx.activity.result.contract.ActivityResultContract + +/** + * ActivityResultContract for selecting a folder via ACTION_OPEN_DOCUMENT_TREE. + * + * The returned Uri can be persisted with takePersistableUriPermission for long-term access. + */ +class OpenDocumentTreeContract : ActivityResultContract() { + + override fun createIntent(context: Context, input: Uri?): Intent { + return Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { + if (Build.VERSION.SDK_INT >= 26 && input != null) { + putExtra(DocumentsContract.EXTRA_INITIAL_URI, input) + } + + addFlags( + Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION or + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } + } + + override fun parseResult(resultCode: Int, intent: Intent?): Uri? { + return if (resultCode == Activity.RESULT_OK) intent?.data else null + } +} diff --git a/core/ui/src/main/java/org/signal/core/ui/navigation/BottomSheetSceneStrategy.kt b/core/ui/src/main/java/org/signal/core/ui/navigation/BottomSheetSceneStrategy.kt new file mode 100644 index 0000000000..5da38d37d0 --- /dev/null +++ b/core/ui/src/main/java/org/signal/core/ui/navigation/BottomSheetSceneStrategy.kt @@ -0,0 +1,112 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.core.ui.navigation + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.ModalBottomSheetProperties +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.scene.OverlayScene +import androidx.navigation3.scene.Scene +import androidx.navigation3.scene.SceneStrategy +import androidx.navigation3.scene.SceneStrategyScope +import kotlinx.coroutines.launch +import org.signal.core.ui.compose.BottomSheets +import org.signal.core.ui.navigation.BottomSheetSceneStrategy.Companion.bottomSheet + +/** + * A [CompositionLocal] that provides a dismiss callback for the current bottom sheet. + * When invoked, the sheet will animate its hide transition and then pop the backstack. + * + * This is the preferred way for bottom sheet content to programmatically dismiss itself, + * as directly manipulating the backstack or using the Activity's back press dispatcher + * will skip the sheet's exit animation. + */ +val LocalBottomSheetDismiss = staticCompositionLocalOf<() -> Unit> { {} } + +/** An [OverlayScene] that renders an [entry] within a [ModalBottomSheet]. */ +@OptIn(ExperimentalMaterial3Api::class) +internal class BottomSheetScene( + override val key: T, + override val previousEntries: List>, + override val overlaidEntries: List>, + private val entry: NavEntry, + private val modalBottomSheetProperties: ModalBottomSheetProperties, + private val onBack: () -> Unit +) : OverlayScene { + + override val entries: List> = listOf(entry) + + override val content: @Composable (() -> Unit) = { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val scope = rememberCoroutineScope() + + val animatedDismiss: () -> Unit = { + scope.launch { sheetState.hide() }.invokeOnCompletion { + if (!sheetState.isVisible) { + onBack() + } + } + } + + CompositionLocalProvider(LocalBottomSheetDismiss provides animatedDismiss) { + BottomSheets.BottomSheet( + onDismissRequest = onBack, + sheetState = sheetState, + properties = modalBottomSheetProperties + ) { + entry.Content() + } + } + } +} + +/** + * A [SceneStrategy] that displays entries that have added [bottomSheet] to their [NavEntry.metadata] + * within a [ModalBottomSheet] instance. + * + * This strategy should always be added before any non-overlay scene strategies. + */ +@OptIn(ExperimentalMaterial3Api::class) +class BottomSheetSceneStrategy : SceneStrategy { + + override fun SceneStrategyScope.calculateScene(entries: List>): Scene? { + val lastEntry = entries.lastOrNull() + val bottomSheetProperties = lastEntry?.metadata?.get(BOTTOM_SHEET_KEY) as? ModalBottomSheetProperties + return bottomSheetProperties?.let { properties -> + @Suppress("UNCHECKED_CAST") + BottomSheetScene( + key = lastEntry.contentKey as T, + previousEntries = entries.dropLast(1), + overlaidEntries = entries.dropLast(1), + entry = lastEntry, + modalBottomSheetProperties = properties, + onBack = onBack + ) + } + } + + companion object { + /** + * Function to be called on the [NavEntry.metadata] to mark this entry as something that + * should be displayed within a [ModalBottomSheet]. + * + * @param modalBottomSheetProperties properties that should be passed to the containing + * [ModalBottomSheet]. + */ + @OptIn(ExperimentalMaterial3Api::class) + fun bottomSheet( + modalBottomSheetProperties: ModalBottomSheetProperties = ModalBottomSheetProperties() + ): Map = mapOf(BOTTOM_SHEET_KEY to modalBottomSheetProperties) + + internal const val BOTTOM_SHEET_KEY = "bottomsheet" + } +}