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"
+ }
+}