diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/local/ArchiveFileSystem.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/local/ArchiveFileSystem.kt
index ab2fdfc5c8..49e7adc746 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/local/ArchiveFileSystem.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/local/ArchiveFileSystem.kt
@@ -37,6 +37,7 @@ class ArchiveFileSystem private constructor(private val context: Context, root:
companion object {
val TAG = Log.tag(ArchiveFileSystem::class.java)
+ const val MAIN_DIRECTORY_NAME = "SignalBackups"
const val BACKUP_DIRECTORY_PREFIX: String = "signal-backup"
const val TEMP_BACKUP_DIRECTORY_SUFFIX: String = "tmp"
@@ -75,7 +76,7 @@ class ArchiveFileSystem private constructor(private val context: Context, root:
val filesFileSystem: FilesFileSystem
init {
- signalBackups = root.mkdirp("SignalBackups") ?: throw IOException("Unable to create main backups directory")
+ signalBackups = root.mkdirp(MAIN_DIRECTORY_NAME) ?: throw IOException("Unable to create main backups directory")
val filesDirectory = signalBackups.mkdirp("files") ?: throw IOException("Unable to create files directory")
filesFileSystem = FilesFileSystem(context, filesDirectory)
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowFragment.kt
index 04b6ad10e0..7294730f30 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowFragment.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowFragment.kt
@@ -40,6 +40,7 @@ import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.compose.Nav
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.dependencies.AppDependencies
+import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.PlayStoreUtil
import org.thoughtcrime.securesms.util.Util
@@ -159,7 +160,8 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
onRequestSaveToPasswordManager = viewModel::onBackupKeySaveRequested,
onConfirmSaveToPasswordManager = viewModel::onBackupKeySaveConfirmed,
onSaveToPasswordManagerComplete = viewModel::onBackupKeySaveCompleted,
- onGoToPasswordManagerSettingsClick = { requireContext().startActivity(passwordManagerSettingsIntent) }
+ onGoToPasswordManagerSettingsClick = { requireContext().startActivity(passwordManagerSettingsIntent) },
+ notifyKeyIsSameAsOnDeviceBackupKey = SignalStore.backup.newLocalBackupsEnabled
)
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyEducationScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyEducationScreen.kt
index 42b081129d..24944b159a 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyEducationScreen.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyEducationScreen.kt
@@ -8,8 +8,10 @@ package org.thoughtcrime.securesms.backup.v2.ui.subscription
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -17,6 +19,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -29,23 +32,47 @@ import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
+import org.signal.core.ui.compose.horizontalGutters
import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.compose.SignalTheme
import org.signal.core.ui.R as CoreUiR
+enum class MessageBackupsKeyEducationScreenMode {
+ /**
+ * Displayed when the user is enabling remote backups and does not have unified local backups enabled
+ */
+ REMOTE_BACKUP_WITH_LOCAL_DISABLED,
+
+ /**
+ * Displayed when the user is upgrading legacy to unified local backup
+ */
+ LOCAL_BACKUP_UPGRADE,
+
+ /**
+ * Displayed when the user has unified local backup and is enabling remote backups
+ */
+ REMOTE_BACKUP_WITH_LOCAL_ENABLED
+}
+
/**
* Screen detailing how a backups key is used to restore a backup
*/
@Composable
fun MessageBackupsKeyEducationScreen(
onNavigationClick: () -> Unit = {},
- onNextClick: () -> Unit = {}
+ onNextClick: () -> Unit = {},
+ mode: MessageBackupsKeyEducationScreenMode = MessageBackupsKeyEducationScreenMode.REMOTE_BACKUP_WITH_LOCAL_DISABLED
) {
val scrollState = rememberScrollState()
@@ -71,26 +98,24 @@ fun MessageBackupsKeyEducationScreen(
)
Text(
- text = stringResource(R.string.MessageBackupsKeyEducationScreen__your_backup_key),
+ text = getTitleText(mode),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(top = 16.dp)
)
- InfoRow(
- R.drawable.symbol_number_24,
- R.string.MessageBackupsKeyEducationScreen__your_backup_key_is_a
- )
+ when (mode) {
+ MessageBackupsKeyEducationScreenMode.REMOTE_BACKUP_WITH_LOCAL_DISABLED -> {
+ RemoteBackupWithLocalDisabledInfo()
+ }
- InfoRow(
- CoreUiR.drawable.symbol_lock_24,
- R.string.MessageBackupsKeyEducationScreen__store_your_recovery
- )
-
- InfoRow(
- R.drawable.symbol_error_circle_24,
- R.string.MessageBackupsKeyEducationScreen__if_you_lose_it
- )
+ MessageBackupsKeyEducationScreenMode.LOCAL_BACKUP_UPGRADE -> {
+ LocalBackupUpgradeInfo()
+ }
+ MessageBackupsKeyEducationScreenMode.REMOTE_BACKUP_WITH_LOCAL_ENABLED -> {
+ RemoteBackupWithLocalEnabledInfo()
+ }
+ }
Spacer(
modifier = Modifier
@@ -117,6 +142,154 @@ fun MessageBackupsKeyEducationScreen(
}
}
+@Composable
+private fun getTitleText(mode: MessageBackupsKeyEducationScreenMode): String {
+ return when (mode) {
+ MessageBackupsKeyEducationScreenMode.REMOTE_BACKUP_WITH_LOCAL_DISABLED -> stringResource(R.string.MessageBackupsKeyEducationScreen__your_backup_key)
+ MessageBackupsKeyEducationScreenMode.LOCAL_BACKUP_UPGRADE -> stringResource(R.string.MessageBackupsKeyEducationScreen__your_new_recovery_key)
+ MessageBackupsKeyEducationScreenMode.REMOTE_BACKUP_WITH_LOCAL_ENABLED -> stringResource(R.string.MessageBackupsKeyEducationScreen__your_recovery_key)
+ }
+}
+
+@Composable
+private fun LocalBackupUpgradeInfo() {
+ val normalText = stringResource(R.string.MessageBackupsKeyEducationScreen__local_backup_upgrade_description)
+ val boldText = stringResource(R.string.MessageBackupsKeyEducationScreen__local_backup_upgrade_description_bold)
+
+ DescriptionText(
+ normalText = normalText,
+ boldText = boldText
+ )
+
+ UseThisKeyToContainer {
+ UseThisKeyToRow(
+ icon = ImageVector.vectorResource(R.drawable.symbol_folder_24),
+ text = stringResource(R.string.MessageBackupsKeyEducationScreen__restore_on_device_backup)
+ )
+
+ Spacer(modifier = Modifier.padding(vertical = 16.dp))
+
+ UseThisKeyToRow(
+ icon = ImageVector.vectorResource(CoreUiR.drawable.symbol_backup_24),
+ text = stringResource(R.string.MessageBackupsKeyEducationScreen__restore_a_signal_secure_backup)
+ )
+ }
+}
+
+@Composable
+private fun RemoteBackupWithLocalEnabledInfo() {
+ val normalText = stringResource(R.string.MessageBackupsKeyEducationScreen__remote_backup_with_local_enabled_description)
+ val boldText = stringResource(R.string.MessageBackupsKeyEducationScreen__remote_backup_with_local_enabled_description_bold)
+
+ DescriptionText(
+ normalText = normalText,
+ boldText = boldText
+ )
+
+ UseThisKeyToContainer {
+ UseThisKeyToRow(
+ icon = ImageVector.vectorResource(CoreUiR.drawable.symbol_backup_24),
+ text = stringResource(R.string.MessageBackupsKeyEducationScreen__restore_your_signal_secure_backup)
+ )
+
+ Spacer(modifier = Modifier.padding(vertical = 16.dp))
+
+ UseThisKeyToRow(
+ icon = ImageVector.vectorResource(R.drawable.symbol_folder_24),
+ text = stringResource(R.string.MessageBackupsKeyEducationScreen__restore_on_device_backup)
+ )
+ }
+}
+
+@Composable
+private fun DescriptionText(
+ normalText: String,
+ boldText: String
+) {
+ Text(
+ text = buildAnnotatedString {
+ append(normalText)
+ append(" ")
+ withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
+ append(boldText)
+ }
+ },
+ textAlign = TextAlign.Center,
+ modifier = Modifier
+ .padding(top = 12.dp)
+ .horizontalGutters()
+ )
+}
+
+@Composable
+private fun UseThisKeyToContainer(
+ modifier: Modifier = Modifier,
+ content: @Composable ColumnScope.() -> Unit
+) {
+ Column(
+ modifier = modifier
+ .padding(top = 28.dp)
+ .horizontalGutters()
+ .fillMaxWidth()
+ .background(color = SignalTheme.colors.colorSurface1, shape = RoundedCornerShape(10.dp))
+ .padding(24.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.MessageBackupsKeyEducationScreen__use_this_key_to),
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(bottom = 14.dp)
+ )
+
+ content()
+ }
+}
+
+@Composable
+private fun UseThisKeyToRow(
+ icon: ImageVector,
+ text: String,
+ modifier: Modifier = Modifier
+) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(start = 12.dp)
+ ) {
+ Icon(
+ imageVector = icon,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ contentDescription = null,
+ modifier = Modifier.size(24.dp)
+ )
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(start = 16.dp)
+ )
+ }
+}
+
+@Composable
+private fun RemoteBackupWithLocalDisabledInfo() {
+ InfoRow(
+ R.drawable.symbol_number_24,
+ R.string.MessageBackupsKeyEducationScreen__your_backup_key_is_a
+ )
+
+ InfoRow(
+ CoreUiR.drawable.symbol_lock_24,
+ R.string.MessageBackupsKeyEducationScreen__store_your_recovery
+ )
+
+ InfoRow(
+ R.drawable.symbol_error_circle_24,
+ R.string.MessageBackupsKeyEducationScreen__if_you_lose_it
+ )
+}
+
@Composable
private fun InfoRow(@DrawableRes iconId: Int, @StringRes textId: Int) {
Row(
@@ -140,8 +313,30 @@ private fun InfoRow(@DrawableRes iconId: Int, @StringRes textId: Int) {
@DayNightPreviews
@Composable
-private fun MessageBackupsKeyEducationScreenPreview() {
+private fun MessageBackupsKeyEducationScreenRemoteBackupWithLocalDisabledPreview() {
Previews.Preview {
- MessageBackupsKeyEducationScreen()
+ MessageBackupsKeyEducationScreen(
+ mode = MessageBackupsKeyEducationScreenMode.REMOTE_BACKUP_WITH_LOCAL_DISABLED
+ )
+ }
+}
+
+@DayNightPreviews
+@Composable
+private fun MessageBackupsKeyEducationScreenLocalBackupUpgradePreview() {
+ Previews.Preview {
+ MessageBackupsKeyEducationScreen(
+ mode = MessageBackupsKeyEducationScreenMode.LOCAL_BACKUP_UPGRADE
+ )
+ }
+}
+
+@DayNightPreviews
+@Composable
+private fun MessageBackupsKeyEducationScreenRemoteBackupWithLocalEnabledPreview() {
+ Previews.Preview {
+ MessageBackupsKeyEducationScreen(
+ mode = MessageBackupsKeyEducationScreenMode.REMOTE_BACKUP_WITH_LOCAL_ENABLED
+ )
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyRecordScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyRecordScreen.kt
index 91a72e3b42..da601b5859 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyRecordScreen.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyRecordScreen.kt
@@ -58,13 +58,18 @@ import org.signal.core.ui.compose.Snackbars
import org.signal.core.ui.compose.horizontalGutters
import org.signal.core.ui.compose.theme.SignalTheme
import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeyCredentialManagerHandler
import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeySaveState
import org.thoughtcrime.securesms.fonts.MonoTypeface
+import org.thoughtcrime.securesms.keyvalue.SignalStore
+import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.storage.AndroidCredentialRepository
import org.thoughtcrime.securesms.util.storage.CredentialManagerError
import org.thoughtcrime.securesms.util.storage.CredentialManagerResult
import org.signal.core.ui.R as CoreUiR
+private const val CLIPBOARD_TIMEOUT_SECONDS = 60
+
@Stable
sealed interface MessageBackupsKeyRecordMode {
data class Next(val onNextClick: () -> Unit) : MessageBackupsKeyRecordMode
@@ -76,6 +81,40 @@ sealed interface MessageBackupsKeyRecordMode {
) : MessageBackupsKeyRecordMode
}
+/**
+ * More self-contained version of [MessageBackupsKeyRecordScreen] to try to improve reusability.
+ * This version is not built to be previewed but covers a lot of the repetitive boilerplate seen
+ * elsewhere.
+ */
+@Composable
+fun MessageBackupsKeyRecordScreen(
+ backupKey: String,
+ keySaveState: BackupKeySaveState?,
+ backupKeyCredentialManagerHandler: BackupKeyCredentialManagerHandler,
+ mode: MessageBackupsKeyRecordMode
+) {
+ val context = LocalContext.current
+ val passwordManagerSettingsIntent = remember {
+ AndroidCredentialRepository.getCredentialManagerSettingsIntent(context)
+ }
+
+ val onBackPressedDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
+
+ MessageBackupsKeyRecordScreen(
+ backupKey = backupKey,
+ keySaveState = keySaveState,
+ canOpenPasswordManagerSettings = passwordManagerSettingsIntent != null,
+ onNavigationClick = { onBackPressedDispatcher?.onBackPressed() },
+ onCopyToClipboardClick = { Util.copyToClipboard(context, it, CLIPBOARD_TIMEOUT_SECONDS) },
+ onRequestSaveToPasswordManager = backupKeyCredentialManagerHandler::onBackupKeySaveRequested,
+ onConfirmSaveToPasswordManager = backupKeyCredentialManagerHandler::onBackupKeySaveConfirmed,
+ onSaveToPasswordManagerComplete = backupKeyCredentialManagerHandler::onBackupKeySaveCompleted,
+ mode = mode,
+ onGoToPasswordManagerSettingsClick = { context.startActivity(passwordManagerSettingsIntent) },
+ notifyKeyIsSameAsOnDeviceBackupKey = SignalStore.backup.newLocalBackupsEnabled
+ )
+}
+
/**
* Screen displaying the backup key allowing the user to write it down
* or copy it.
@@ -91,7 +130,8 @@ fun MessageBackupsKeyRecordScreen(
onConfirmSaveToPasswordManager: () -> Unit = {},
onSaveToPasswordManagerComplete: (CredentialManagerResult) -> Unit = {},
onGoToPasswordManagerSettingsClick: () -> Unit = {},
- mode: MessageBackupsKeyRecordMode = MessageBackupsKeyRecordMode.Next(onNextClick = {})
+ mode: MessageBackupsKeyRecordMode = MessageBackupsKeyRecordMode.Next(onNextClick = {}),
+ notifyKeyIsSameAsOnDeviceBackupKey: Boolean = false
) {
val snackbarHostState = remember { SnackbarHostState() }
val backupKeyString = remember(backupKey) {
@@ -142,8 +182,14 @@ fun MessageBackupsKeyRecordScreen(
}
item {
+ val text = if (notifyKeyIsSameAsOnDeviceBackupKey) {
+ stringResource(R.string.MessageBackupsKeyRecordScreen__this_key_is_the_same_as_your_on_device_backup_key)
+ } else {
+ stringResource(R.string.MessageBackupsKeyRecordScreen__this_key_is_required_to_recover)
+ }
+
Text(
- text = stringResource(R.string.MessageBackupsKeyRecordScreen__this_key_is_required_to_recover),
+ text = text,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -199,23 +245,14 @@ fun MessageBackupsKeyRecordScreen(
}
}
- if (mode is MessageBackupsKeyRecordMode.Next) {
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .padding(bottom = 24.dp)
- ) {
- Buttons.LargeTonal(
- onClick = mode.onNextClick,
- modifier = Modifier.align(Alignment.BottomEnd)
- ) {
- Text(
- text = stringResource(R.string.MessageBackupsKeyRecordScreen__next)
- )
- }
+ when (mode) {
+ is MessageBackupsKeyRecordMode.Next -> {
+ NextButton(onNextClick = mode.onNextClick)
+ }
+
+ is MessageBackupsKeyRecordMode.CreateNewKey -> {
+ CreateNewKeyButton(mode)
}
- } else if (mode is MessageBackupsKeyRecordMode.CreateNewKey) {
- CreateNewKeyButton(mode)
}
}
@@ -259,6 +296,24 @@ fun MessageBackupsKeyRecordScreen(
}
}
+@Composable
+private fun NextButton(onNextClick: () -> Unit) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 24.dp)
+ ) {
+ Buttons.LargeTonal(
+ onClick = onNextClick,
+ modifier = Modifier.align(Alignment.BottomEnd)
+ ) {
+ Text(
+ text = stringResource(R.string.MessageBackupsKeyRecordScreen__next)
+ )
+ }
+ }
+}
+
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CreateNewKeyButton(
@@ -505,6 +560,20 @@ private fun MessageBackupsKeyRecordScreenPreview() {
}
}
+@DayNightPreviews
+@Composable
+private fun MessageBackupsKeyRecordScreenSameAsOnDeviceKeyPreview() {
+ Previews.Preview {
+ MessageBackupsKeyRecordScreen(
+ backupKey = (0 until 63).map { (('A'..'Z') + ('0'..'9')).random() }.joinToString("") + "0",
+ keySaveState = null,
+ canOpenPasswordManagerSettings = true,
+ mode = MessageBackupsKeyRecordMode.Next(onNextClick = {}),
+ notifyKeyIsSameAsOnDeviceBackupKey = true
+ )
+ }
+}
+
@DayNightPreviews
@Composable
private fun SaveKeyConfirmationDialogPreview() {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/verify/ForgotBackupKeyFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/verify/ForgotBackupKeyFragment.kt
index 91d2a76242..bc2f759c51 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/verify/ForgotBackupKeyFragment.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/verify/ForgotBackupKeyFragment.kt
@@ -9,8 +9,6 @@ import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsKeyRec
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsKeyRecordScreen
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.keyvalue.SignalStore
-import org.thoughtcrime.securesms.util.Util
-import org.thoughtcrime.securesms.util.storage.AndroidCredentialRepository
import org.thoughtcrime.securesms.util.viewModel
/**
@@ -27,17 +25,11 @@ class ForgotBackupKeyFragment : ComposeFragment() {
@Composable
override fun FragmentContent() {
val state by viewModel.uiState.collectAsStateWithLifecycle()
- val passwordManagerSettingsIntent = AndroidCredentialRepository.getCredentialManagerSettingsIntent(requireContext())
MessageBackupsKeyRecordScreen(
backupKey = SignalStore.account.accountEntropyPool.displayValue,
keySaveState = state.keySaveState,
- canOpenPasswordManagerSettings = passwordManagerSettingsIntent != null,
- onNavigationClick = { requireActivity().supportFragmentManager.popBackStack() },
- onCopyToClipboardClick = { Util.copyToClipboard(requireContext(), it, CLIPBOARD_TIMEOUT_SECONDS) },
- onRequestSaveToPasswordManager = viewModel::onBackupKeySaveRequested,
- onConfirmSaveToPasswordManager = viewModel::onBackupKeySaveConfirmed,
- onSaveToPasswordManagerComplete = viewModel::onBackupKeySaveCompleted,
+ backupKeyCredentialManagerHandler = viewModel,
mode = remember {
MessageBackupsKeyRecordMode.Next(onNextClick = {
requireActivity()
@@ -47,8 +39,7 @@ class ForgotBackupKeyFragment : ComposeFragment() {
.addToBackStack(null)
.commit()
})
- },
- onGoToPasswordManagerSettingsClick = { requireContext().startActivity(passwordManagerSettingsIntent) }
+ }
)
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/verify/VerifyBackupKeyActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/verify/VerifyBackupKeyActivity.kt
index 19aa2fa5ae..72815b617a 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/verify/VerifyBackupKeyActivity.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/verify/VerifyBackupKeyActivity.kt
@@ -6,9 +6,6 @@ import android.os.Bundle
import android.widget.Toast
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
-import androidx.activity.result.ActivityResultLauncher
-import androidx.biometric.BiometricManager
-import androidx.biometric.BiometricPrompt
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
@@ -27,13 +24,10 @@ import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
-import org.signal.core.util.logging.Log
-import org.thoughtcrime.securesms.BiometricDeviceAuthentication
-import org.thoughtcrime.securesms.BiometricDeviceLockContract
-import org.thoughtcrime.securesms.DevicePinAuthEducationSheet
import org.thoughtcrime.securesms.PassphraseRequiredActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.ui.subscription.EnterKeyScreen
+import org.thoughtcrime.securesms.components.compose.rememberBiometricsAuthentication
import org.thoughtcrime.securesms.compose.SignalTheme
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.CommunicationActions
@@ -46,8 +40,6 @@ import kotlin.random.nextInt
class VerifyBackupKeyActivity : PassphraseRequiredActivity() {
companion object {
- private val TAG = Log.tag(VerifyBackupKeyActivity::class)
-
@JvmStatic
fun createIntent(context: Context): Intent {
return Intent(context, VerifyBackupKeyActivity::class.java)
@@ -56,25 +48,29 @@ class VerifyBackupKeyActivity : PassphraseRequiredActivity() {
const val REQUEST_CODE = 1204
}
- private lateinit var biometricDeviceAuthentication: BiometricDeviceAuthentication
- private lateinit var biometricDeviceLockLauncher: ActivityResultLauncher
-
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
enableEdgeToEdge()
setContent {
SignalTheme {
+ val context = LocalContext.current
+ val biometrics = rememberBiometricsAuthentication(
+ promptTitle = stringResource(R.string.RemoteBackupsSettingsFragment__unlock_to_view_backup_key),
+ educationSheetMessage = stringResource(R.string.RemoteBackupsSettingsFragment__to_view_your_key),
+ onAuthenticationFailed = {
+ // Matches existing behavior: show a generic "authentication required" toast.
+ Toast.makeText(
+ context,
+ R.string.RemoteBackupsSettingsFragment__authenticatino_required,
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ )
+
VerifyBackupPinScreen(
backupKey = SignalStore.account.accountEntropyPool.displayValue,
onForgotKeyClick = {
- if (biometricDeviceAuthentication.shouldShowEducationSheet(this)) {
- DevicePinAuthEducationSheet.show(getString(R.string.RemoteBackupsSettingsFragment__to_view_your_key), supportFragmentManager)
- supportFragmentManager.setFragmentResultListener(DevicePinAuthEducationSheet.REQUEST_KEY, this) { _, _ ->
- if (!biometricDeviceAuthentication.authenticate(this, true) { biometricDeviceLockLauncher.launch(getString(R.string.RemoteBackupsSettingsFragment__unlock_to_view_backup_key)) }) {
- displayBackupKey()
- }
- }
- } else if (!biometricDeviceAuthentication.authenticate(this, true) { biometricDeviceLockLauncher.launch(getString(R.string.RemoteBackupsSettingsFragment__unlock_to_view_backup_key)) }) {
+ biometrics.withBiometricsAuthentication {
displayBackupKey()
}
},
@@ -88,23 +84,6 @@ class VerifyBackupKeyActivity : PassphraseRequiredActivity() {
)
}
}
-
- initializeBiometricAuth()
- }
-
- private fun initializeBiometricAuth() {
- val biometricPrompt = BiometricPrompt(this, AuthListener())
- val promptInfo: BiometricPrompt.PromptInfo = BiometricPrompt.PromptInfo.Builder()
- .setAllowedAuthenticators(BiometricDeviceAuthentication.ALLOWED_AUTHENTICATORS)
- .setTitle(getString(R.string.RemoteBackupsSettingsFragment__unlock_to_view_backup_key))
- .build()
-
- biometricDeviceAuthentication = BiometricDeviceAuthentication(BiometricManager.from(this), biometricPrompt, promptInfo)
- biometricDeviceLockLauncher = registerForActivityResult(BiometricDeviceLockContract()) { result: Int ->
- if (result == BiometricDeviceAuthentication.AUTHENTICATED) {
- displayBackupKey()
- }
- }
}
private fun displayBackupKey() {
@@ -114,23 +93,6 @@ class VerifyBackupKeyActivity : PassphraseRequiredActivity() {
.addToBackStack(null)
.commit()
}
-
- private inner class AuthListener : BiometricPrompt.AuthenticationCallback() {
- override fun onAuthenticationFailed() {
- Log.w(TAG, "onAuthenticationFailed")
- Toast.makeText(this@VerifyBackupKeyActivity, R.string.RemoteBackupsSettingsFragment__authenticatino_required, Toast.LENGTH_SHORT).show()
- }
-
- override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
- Log.i(TAG, "onAuthenticationSucceeded")
- displayBackupKey()
- }
-
- override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
- Log.w(TAG, "onAuthenticationError: $errorCode, $errString")
- onAuthenticationFailed()
- }
- }
}
@Composable
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/compose/BiometricsAuthentication.kt b/app/src/main/java/org/thoughtcrime/securesms/components/compose/BiometricsAuthentication.kt
new file mode 100644
index 0000000000..2c2a6c26e9
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/compose/BiometricsAuthentication.kt
@@ -0,0 +1,199 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+package org.thoughtcrime.securesms.components.compose
+
+import android.content.Context
+import android.view.View
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.biometric.BiometricManager
+import androidx.biometric.BiometricPrompt
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalInspectionMode
+import androidx.compose.ui.platform.LocalView
+import androidx.core.content.ContextCompat
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.FragmentActivity
+import androidx.fragment.app.FragmentManager
+import androidx.fragment.app.findFragment
+import androidx.lifecycle.LifecycleOwner
+import org.thoughtcrime.securesms.BiometricDeviceAuthentication
+import org.thoughtcrime.securesms.BiometricDeviceLockContract
+import org.thoughtcrime.securesms.DevicePinAuthEducationSheet
+
+@Stable
+class BiometricsAuthentication internal constructor(
+ private val authenticateImpl: (onAuthenticated: () -> Unit) -> Unit,
+ private val cancelImpl: () -> Unit
+) {
+ fun withBiometricsAuthentication(onAuthenticated: () -> Unit) {
+ authenticateImpl(onAuthenticated)
+ }
+
+ fun cancelAuthentication() {
+ cancelImpl()
+ }
+}
+
+/**
+ * A lightweight helper for prompting the user for biometric/device-credential authentication from Compose.
+ *
+ * Intended usage:
+ *
+ * - `val biometrics = rememberBiometricsAuthentication(...)`
+ * - `onClick = { biometrics.withBiometricsAuthentication { performAction() } }`
+ */
+@Composable
+fun rememberBiometricsAuthentication(
+ promptTitle: String? = null,
+ educationSheetMessage: String? = null,
+ onAuthenticationFailed: (() -> Unit)? = null
+): BiometricsAuthentication {
+ if (LocalInspectionMode.current) {
+ return remember {
+ BiometricsAuthentication(
+ authenticateImpl = { it.invoke() },
+ cancelImpl = {}
+ )
+ }
+ }
+
+ val context = LocalContext.current
+ val view = LocalView.current
+ val host = remember(view, context) { resolveHost(context, view) }
+
+ if (host == null) {
+ error("FragmentActivity is required to use rememberBiometricsAuthentication()")
+ }
+
+ val resolvedTitle = promptTitle?.takeIf { it.isNotBlank() }
+ check(resolvedTitle != null) {
+ "promptTitle must be non-blank when using rememberBiometricsAuthentication()"
+ }
+
+ // Fallback to device credential confirmation when BiometricPrompt isn't available.
+ var pendingAction by remember { mutableStateOf<(() -> Unit)?>(null) }
+ val deviceCredentialLauncher = rememberLauncherForActivityResult(BiometricDeviceLockContract()) { result ->
+ if (result == BiometricDeviceAuthentication.AUTHENTICATED) {
+ pendingAction?.invoke()
+ pendingAction = null
+ }
+ }
+
+ val biometricManager = remember(context) { BiometricManager.from(context) }
+
+ val biometricPrompt = remember(host.activity, host.fragment, context) {
+ val executor = ContextCompat.getMainExecutor(context)
+ val callback = object : BiometricPrompt.AuthenticationCallback() {
+ override fun onAuthenticationFailed() {
+ onAuthenticationFailed?.invoke()
+ }
+
+ override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
+ pendingAction?.invoke()
+ pendingAction = null
+ }
+
+ override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
+ onAuthenticationFailed?.invoke()
+ }
+ }
+
+ host.fragment?.let { fragment ->
+ BiometricPrompt(fragment, executor, callback)
+ } ?: BiometricPrompt(host.activity, executor, callback)
+ }
+
+ val biometricDeviceAuthentication = remember(biometricManager, biometricPrompt) {
+ // Prompt info is updated below on each call to `withBiometricsAuthentication`.
+ val initialPromptInfo = BiometricPrompt.PromptInfo.Builder()
+ .setAllowedAuthenticators(BiometricDeviceAuthentication.ALLOWED_AUTHENTICATORS)
+ .setTitle(" ")
+ .build()
+ BiometricDeviceAuthentication(biometricManager, biometricPrompt, initialPromptInfo)
+ }
+
+ val shouldShowEducationSheetForFlow = biometricDeviceAuthentication.shouldShowEducationSheet(context)
+
+ fun authenticateOrFallback(promptTitleForPrompt: String) {
+ val action = pendingAction ?: return
+ val promptInfo = BiometricPrompt.PromptInfo.Builder()
+ .setAllowedAuthenticators(BiometricDeviceAuthentication.ALLOWED_AUTHENTICATORS)
+ .setTitle(promptTitleForPrompt)
+ .build()
+ biometricDeviceAuthentication.updatePromptInfo(promptInfo)
+
+ if (!biometricDeviceAuthentication.authenticate(context, true) {
+ deviceCredentialLauncher.launch(promptTitleForPrompt)
+ }
+ ) {
+ // If we cannot authenticate at all, preserve existing call-site behavior and just proceed.
+ action.invoke()
+ pendingAction = null
+ }
+ }
+
+ // If the composable that owns this helper leaves composition (navigation, conditional UI, etc.),
+ // ensure we don't keep an auth prompt open or deliver a stale callback later.
+ DisposableEffect(biometricDeviceAuthentication) {
+ onDispose {
+ biometricDeviceAuthentication.cancelAuthentication()
+ pendingAction = null
+ }
+ }
+
+ return BiometricsAuthentication(
+ authenticateImpl = { onAuthenticated ->
+ pendingAction = onAuthenticated
+
+ if (shouldShowEducationSheetForFlow && !educationSheetMessage.isNullOrBlank()) {
+ DevicePinAuthEducationSheet.show(educationSheetMessage, host.fragmentManager)
+ host.fragmentManager.setFragmentResultListener(
+ DevicePinAuthEducationSheet.REQUEST_KEY,
+ host.resultLifecycleOwner
+ ) { _, _ ->
+ authenticateOrFallback(resolvedTitle)
+ }
+ } else {
+ authenticateOrFallback(resolvedTitle)
+ }
+ },
+ cancelImpl = biometricDeviceAuthentication::cancelAuthentication
+ )
+}
+
+@Stable
+private data class Host(
+ val activity: FragmentActivity,
+ val fragment: Fragment?,
+ val fragmentManager: FragmentManager,
+ val resultLifecycleOwner: LifecycleOwner
+)
+
+private fun resolveHost(context: Context, view: View): Host? {
+ val fragment = runCatching { view.findFragment() }.getOrNull()
+ if (fragment != null) {
+ return Host(
+ activity = fragment.requireActivity(),
+ fragment = fragment,
+ fragmentManager = fragment.parentFragmentManager,
+ resultLifecycleOwner = fragment.viewLifecycleOwner
+ )
+ }
+
+ val activity = context as? FragmentActivity ?: return null
+ return Host(
+ activity = activity,
+ fragment = null,
+ fragmentManager = activity.supportFragmentManager,
+ resultLifecycleOwner = activity
+ )
+}
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 2172b4040f..4b659cbc6f 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,6 +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.SignalE164Util
import org.thoughtcrime.securesms.util.navigation.safeNavigate
@@ -48,10 +49,16 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
val startingAction: NavDirections? = if (intent?.categories?.contains(NOTIFICATION_CATEGORY) == true) {
AppSettingsFragmentDirections.actionDirectToNotificationsSettingsFragment()
} else {
- val appSettingsRoute: AppSettingsRoute? = intent?.getParcelableExtraCompat(START_ROUTE, AppSettingsRoute::class.java)
- when (appSettingsRoute) {
+ when (val appSettingsRoute: AppSettingsRoute? = intent?.getParcelableExtraCompat(START_ROUTE, AppSettingsRoute::class.java)) {
AppSettingsRoute.Empty -> null
- AppSettingsRoute.BackupsRoute.Local -> AppSettingsFragmentDirections.actionDirectToBackupsPreferenceFragment()
+ is AppSettingsRoute.BackupsRoute.Local -> {
+ if (SignalStore.backup.newLocalBackupsEnabled || RemoteConfig.unifiedLocalBackups && (!SignalStore.settings.isBackupEnabled || appSettingsRoute.triggerUpdateFlow)) {
+ AppSettingsFragmentDirections.actionDirectToLocalBackupsFragment()
+ .setTriggerUpdateFlow(appSettingsRoute.triggerUpdateFlow)
+ } else {
+ AppSettingsFragmentDirections.actionDirectToBackupsPreferenceFragment()
+ }
+ }
is AppSettingsRoute.HelpRoute.Settings -> AppSettingsFragmentDirections.actionDirectToHelpFragment()
.setStartCategoryIndex(appSettingsRoute.startCategoryIndex)
AppSettingsRoute.DataAndStorageRoute.Proxy -> AppSettingsFragmentDirections.actionDirectToEditProxyFragment()
@@ -152,7 +159,7 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
}
@JvmStatic
- fun backups(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.BackupsRoute.Local)
+ fun backups(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.BackupsRoute.Local())
@JvmStatic
fun help(context: Context, startCategoryIndex: Int = 0): Intent {
@@ -227,6 +234,9 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
@JvmStatic
fun invite(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.Invite)
+ @JvmStatic
+ fun upgradeLocalBackups(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.BackupsRoute.Local(triggerUpdateFlow = true))
+
private fun getIntentForStartLocation(context: Context, startRoute: AppSettingsRoute): Intent {
return Intent(context, AppSettingsActivity::class.java)
.putExtra(ARG_NAV_GRAPH, R.navigation.app_settings_with_change_number)
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 b11d882a51..2ad408fbeb 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
@@ -29,9 +29,11 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -56,6 +58,7 @@ import org.thoughtcrime.securesms.compose.ComposeFragment
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.navigation.safeNavigate
import java.math.BigDecimal
import java.util.Currency
@@ -101,7 +104,13 @@ class BackupsSettingsFragment : ComposeFragment() {
}
}
},
- onOnDeviceBackupsRowClick = { findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_backupsPreferenceFragment) },
+ onOnDeviceBackupsRowClick = {
+ if (SignalStore.backup.newLocalBackupsEnabled || RemoteConfig.unifiedLocalBackups && !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) }
)
@@ -228,6 +237,7 @@ private fun BackupsSettingsContent(
item {
Rows.TextRow(
text = stringResource(R.string.RemoteBackupsSettingsFragment__on_device_backups),
+ icon = ImageVector.vectorResource(R.drawable.symbol_device_phone_24),
label = stringResource(R.string.RemoteBackupsSettingsFragment__save_your_backups_to),
onClick = onOnDeviceBackupsRowClick
)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/BackupProgressState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/BackupProgressState.kt
new file mode 100644
index 0000000000..344d16de39
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/BackupProgressState.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.components.settings.app.backups.local
+
+/**
+ * Progress indicator state for the on-device backups creation/verification workflow.
+ */
+sealed class BackupProgressState {
+ data object Idle : BackupProgressState()
+
+ /**
+ * Represents either backup creation or verification progress.
+ *
+ * @param summary High-level status label (e.g. "In progress…", "Verifying backup…")
+ * @param percentLabel Secondary progress label (either a percent string or a count-based string)
+ * @param progressFraction Optional progress fraction in \\([0, 1]\\). Null indicates indeterminate progress.
+ */
+ data class InProgress(
+ val summary: String,
+ val percentLabel: String,
+ val progressFraction: Float?
+ ) : BackupProgressState()
+}
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
new file mode 100644
index 0000000000..40a9c86216
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsFragment.kt
@@ -0,0 +1,177 @@
+/*
+ * Copyright 2026 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.Intent
+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
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.lifecycle.compose.LifecycleResumeEffect
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.navigation.fragment.navArgs
+import androidx.navigation3.runtime.NavBackStack
+import androidx.navigation3.runtime.NavEntry
+import androidx.navigation3.runtime.NavKey
+import androidx.navigation3.runtime.rememberNavBackStack
+import androidx.navigation3.ui.NavDisplay
+import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner
+import kotlinx.coroutines.launch
+import org.signal.core.util.logging.Log
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsKeyEducationScreen
+import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsKeyEducationScreenMode
+import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsKeyRecordMode
+import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsKeyRecordScreen
+import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsKeyVerifyScreen
+import org.thoughtcrime.securesms.compose.ComposeFragment
+import org.thoughtcrime.securesms.keyvalue.SignalStore
+
+private val TAG = Log.tag(LocalBackupsFragment::class)
+
+/**
+ * On-device backups settings screen, replaces `BackupsPreferenceFragment` and contains the key upgrade flow.
+ */
+class LocalBackupsFragment : ComposeFragment() {
+
+ private val args: LocalBackupsFragmentArgs by navArgs()
+
+ @Composable
+ override fun FragmentContent() {
+ val initialStack = if (args.triggerUpdateFlow) {
+ arrayOf(LocalBackupsNavKey.IMPROVEMENTS)
+ } else {
+ arrayOf(LocalBackupsNavKey.SETTINGS)
+ }
+
+ val backstack = rememberNavBackStack(*initialStack)
+ val snackbarHostState = remember { SnackbarHostState() }
+ val viewModel = viewModel()
+
+ CompositionLocalProvider(LocalNavigationEventDispatcherOwner provides requireActivity()) {
+ NavDisplay(
+ backStack = backstack,
+ entryProvider = { key ->
+ when (key) {
+ LocalBackupsNavKey.SETTINGS -> NavEntry(key) {
+ val chooseBackupLocationLauncher = rememberChooseBackupLocationLauncher(backstack)
+ val state by viewModel.settingsState.collectAsStateWithLifecycle()
+ val callback: LocalBackupsSettingsCallback = remember(
+ chooseBackupLocationLauncher
+ ) {
+ DefaultLocalBackupsSettingsCallback(
+ fragment = this,
+ chooseBackupLocationLauncher = chooseBackupLocationLauncher,
+ viewModel = viewModel
+ )
+ }
+
+ LifecycleResumeEffect(Unit) {
+ viewModel.refreshSettingsState()
+ onPauseOrDispose {}
+ }
+
+ LocalBackupsSettingsScreen(
+ state = state,
+ callback = callback,
+ snackbarHostState = snackbarHostState
+ )
+ }
+
+ LocalBackupsNavKey.IMPROVEMENTS -> NavEntry(key) {
+ val backPressedDispatcher = LocalOnBackPressedDispatcherOwner.current
+
+ LocalBackupsImprovementsScreen(
+ onNavigationClick = { backPressedDispatcher?.onBackPressedDispatcher?.onBackPressed() },
+ onContinueClick = { backstack.add(LocalBackupsNavKey.YOUR_RECOVERY_KEY) }
+ )
+ }
+
+ LocalBackupsNavKey.YOUR_RECOVERY_KEY -> NavEntry(key) {
+ val backPressedDispatcher = LocalOnBackPressedDispatcherOwner.current
+
+ MessageBackupsKeyEducationScreen(
+ onNavigationClick = { backPressedDispatcher?.onBackPressedDispatcher?.onBackPressed() },
+ onNextClick = { backstack.add(LocalBackupsNavKey.RECORD_RECOVERY_KEY) },
+ mode = MessageBackupsKeyEducationScreenMode.LOCAL_BACKUP_UPGRADE
+ )
+ }
+
+ LocalBackupsNavKey.RECORD_RECOVERY_KEY -> NavEntry(key) {
+ val state: LocalBackupsKeyState by viewModel.backupState.collectAsStateWithLifecycle()
+
+ MessageBackupsKeyRecordScreen(
+ backupKey = state.accountEntropyPool.displayValue,
+ keySaveState = state.keySaveState,
+ backupKeyCredentialManagerHandler = viewModel,
+ mode = MessageBackupsKeyRecordMode.Next {
+ backstack.add(LocalBackupsNavKey.CONFIRM_RECOVERY_KEY)
+ }
+ )
+ }
+
+ LocalBackupsNavKey.CONFIRM_RECOVERY_KEY -> NavEntry(key) {
+ val state: LocalBackupsKeyState by viewModel.backupState.collectAsStateWithLifecycle()
+ val scope = rememberCoroutineScope()
+ val backupKeyUpdatedMessage = stringResource(R.string.OnDeviceBackupsFragment__backup_key_updated)
+
+ MessageBackupsKeyVerifyScreen(
+ backupKey = state.accountEntropyPool.displayValue,
+ onNavigationClick = { requireActivity().onBackPressedDispatcher.onBackPressed() },
+ onNextClick = {
+ if (!backstack.contains(LocalBackupsNavKey.SETTINGS)) {
+ backstack.add(0, LocalBackupsNavKey.SETTINGS)
+ }
+
+ backstack.removeAll { it != LocalBackupsNavKey.SETTINGS }
+
+ scope.launch {
+ viewModel.handleUpgrade(requireContext())
+
+ snackbarHostState.showSnackbar(
+ message = backupKeyUpdatedMessage
+ )
+ }
+ }
+ )
+ }
+
+ else -> error("Unknown key: $key")
+ }
+ }
+ )
+ }
+ }
+}
+
+@Composable
+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) {
+ 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)
+ SignalStore.backup.newLocalBackupsDirectory = uri.toString()
+ backStack.add(LocalBackupsNavKey.YOUR_RECOVERY_KEY)
+
+ Toast.makeText(context, context.getString(R.string.OnDeviceBackupsFragment__directory_selected, uri), Toast.LENGTH_SHORT).show()
+ } else {
+ Log.w(TAG, "Unified backup location selection cancelled or failed")
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsImprovementsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsImprovementsScreen.kt
new file mode 100644
index 0000000000..762fd2dc24
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsImprovementsScreen.kt
@@ -0,0 +1,161 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.components.settings.app.backups.local
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.shape.CircleShape
+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.signal.core.ui.compose.Scaffolds
+import org.signal.core.ui.compose.horizontalGutters
+import org.thoughtcrime.securesms.R
+import org.signal.core.ui.R as CoreUiR
+
+/**
+ * Screen explaining the improvements made to the on-device backups experience.
+ */
+@Composable
+fun LocalBackupsImprovementsScreen(
+ onNavigationClick: () -> Unit = {},
+ onContinueClick: () -> Unit = {}
+) {
+ Scaffolds.Settings(
+ title = "",
+ navigationIcon = ImageVector.vectorResource(CoreUiR.drawable.symbol_x_24),
+ onNavigationClick = onNavigationClick
+ ) {
+ Column(
+ Modifier
+ .padding(it)
+ .horizontalGutters()
+ .fillMaxSize()
+ ) {
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ item {
+ Icon(
+ imageVector = ImageVector.vectorResource(R.drawable.symbol_folder_24),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier
+ .padding(top = 24.dp, bottom = 16.dp)
+ .size(80.dp)
+ .background(color = MaterialTheme.colorScheme.secondaryContainer, shape = CircleShape)
+ .padding(16.dp)
+ )
+ }
+
+ item {
+ Text(
+ text = stringResource(R.string.OnDeviceBackupsImprovementsScreen__improvements_to_on_device_backups),
+ style = MaterialTheme.typography.headlineMedium,
+ textAlign = TextAlign.Center
+ )
+ }
+
+ item {
+ Text(
+ text = stringResource(R.string.OnDeviceBackupsImprovementsScreen__your_on_device_backup_will_be_upgraded),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(top = 12.dp, bottom = 36.dp)
+ )
+ }
+
+ item {
+ FeatureRow(
+ imageVector = ImageVector.vectorResource(CoreUiR.drawable.symbol_backup_24),
+ text = stringResource(R.string.OnDeviceBackupsImprovementsScreen__backups_now_save_faster)
+ )
+ }
+
+ item {
+ FeatureRow(
+ imageVector = ImageVector.vectorResource(R.drawable.symbol_folder_24),
+ text = stringResource(R.string.OnDeviceBackupsImprovementsScreen__your_backup_will_be_saved_as_a_folder)
+ )
+ }
+
+ item {
+ FeatureRow(
+ imageVector = ImageVector.vectorResource(R.drawable.symbol_key_24),
+ text = stringResource(R.string.OnDeviceBackupsImprovementsScreen__all_backups_remain_end_to_end_encrypted)
+ )
+ }
+ }
+
+ Box(
+ contentAlignment = Alignment.CenterEnd,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 24.dp)
+ ) {
+ Buttons.LargeTonal(
+ onClick = onContinueClick
+ ) {
+ Text(text = stringResource(R.string.OnDeviceBackupsImprovementsScreen__continue))
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun FeatureRow(
+ imageVector: ImageVector,
+ text: String
+) {
+ Row(
+ modifier = Modifier.padding(bottom = 16.dp)
+ ) {
+ Icon(
+ imageVector = imageVector,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Text(
+ text = text,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier
+ .padding(start = 16.dp)
+ .widthIn(max = 217.dp)
+ )
+ }
+}
+
+@DayNightPreviews
+@Composable
+private fun LocalBackupsImprovementsScreenPreview() {
+ Previews.Preview {
+ LocalBackupsImprovementsScreen()
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsKeyState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsKeyState.kt
new file mode 100644
index 0000000000..d97d794097
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsKeyState.kt
@@ -0,0 +1,15 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.components.settings.app.backups.local
+
+import org.signal.core.models.AccountEntropyPool
+import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeySaveState
+import org.thoughtcrime.securesms.keyvalue.SignalStore
+
+data class LocalBackupsKeyState(
+ val accountEntropyPool: AccountEntropyPool = SignalStore.account.accountEntropyPool,
+ val keySaveState: BackupKeySaveState? = null
+)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsNavKey.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsNavKey.kt
new file mode 100644
index 0000000000..083d4d3095
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsNavKey.kt
@@ -0,0 +1,16 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.components.settings.app.backups.local
+
+import androidx.navigation3.runtime.NavKey
+
+enum class LocalBackupsNavKey : NavKey {
+ SETTINGS,
+ IMPROVEMENTS,
+ YOUR_RECOVERY_KEY,
+ RECORD_RECOVERY_KEY,
+ CONFIRM_RECOVERY_KEY
+}
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
new file mode 100644
index 0000000000..8ace255e4d
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsSettingsCallback.kt
@@ -0,0 +1,166 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+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.text.format.DateFormat
+import android.widget.Toast
+import androidx.activity.result.ActivityResultLauncher
+import androidx.navigation.fragment.findNavController
+import com.google.android.material.timepicker.MaterialTimePicker
+import com.google.android.material.timepicker.TimeFormat
+import org.signal.core.util.logging.Log
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.dependencies.AppDependencies
+import org.thoughtcrime.securesms.jobs.LocalBackupJob
+import org.thoughtcrime.securesms.jobs.LocalBackupJob.enqueueArchive
+import org.thoughtcrime.securesms.keyvalue.SignalStore
+import org.thoughtcrime.securesms.permissions.Permissions
+import org.thoughtcrime.securesms.service.LocalBackupListener
+import org.thoughtcrime.securesms.util.BackupUtil
+import org.thoughtcrime.securesms.util.CommunicationActions
+import org.thoughtcrime.securesms.util.TextSecurePreferences
+import org.thoughtcrime.securesms.util.navigation.safeNavigate
+
+sealed interface LocalBackupsSettingsCallback {
+ fun onNavigationClick()
+ fun onTurnOnClick()
+ fun onCreateBackupClick()
+ fun onPickTimeClick()
+ fun onViewBackupKeyClick()
+ fun onLearnMoreClick()
+ fun onLaunchBackupLocationPickerClick()
+ fun onTurnOffAndDeleteConfirmed()
+
+ object Empty : LocalBackupsSettingsCallback {
+ override fun onNavigationClick() = Unit
+ override fun onTurnOnClick() = Unit
+ override fun onCreateBackupClick() = Unit
+ override fun onPickTimeClick() = Unit
+ override fun onViewBackupKeyClick() = Unit
+ override fun onLearnMoreClick() = Unit
+ override fun onLaunchBackupLocationPickerClick() = Unit
+ override fun onTurnOffAndDeleteConfirmed() = Unit
+ }
+}
+
+class DefaultLocalBackupsSettingsCallback(
+ private val fragment: LocalBackupsFragment,
+ private val chooseBackupLocationLauncher: ActivityResultLauncher,
+ private val viewModel: LocalBackupsViewModel
+) : LocalBackupsSettingsCallback {
+
+ companion object {
+ private val TAG = Log.tag(LocalBackupsSettingsCallback::class)
+ }
+
+ override fun onNavigationClick() {
+ fragment.requireActivity().onBackPressedDispatcher.onBackPressed()
+ }
+
+ 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) {
+ Toast.makeText(fragment.requireContext(), R.string.BackupDialog_no_file_picker_available, Toast.LENGTH_LONG).show()
+ }
+ }
+
+ override fun onPickTimeClick() {
+ val timeFormat = if (DateFormat.is24HourFormat(fragment.requireContext())) {
+ TimeFormat.CLOCK_24H
+ } else {
+ TimeFormat.CLOCK_12H
+ }
+
+ val picker = MaterialTimePicker.Builder()
+ .setTimeFormat(timeFormat)
+ .setHour(SignalStore.settings.backupHour)
+ .setMinute(SignalStore.settings.backupMinute)
+ .setTitleText(R.string.BackupsPreferenceFragment__set_backup_time)
+ .build()
+
+ picker.addOnPositiveButtonClickListener {
+ SignalStore.settings.setBackupSchedule(picker.hour, picker.minute)
+ TextSecurePreferences.setNextBackupTime(fragment.requireContext(), 0)
+ LocalBackupListener.schedule(fragment.requireContext())
+ viewModel.refreshSettingsState()
+ }
+
+ picker.show(fragment.childFragmentManager, "TIME_PICKER")
+ }
+
+ override fun onCreateBackupClick() {
+ if (BackupUtil.isUserSelectionRequired(fragment.requireContext())) {
+ Log.i(TAG, "Queueing backup...")
+ enqueueArchive(false)
+ } else {
+ Permissions.with(fragment)
+ .request(Manifest.permission.WRITE_EXTERNAL_STORAGE)
+ .ifNecessary()
+ .onAllGranted {
+ Log.i(TAG, "Queuing backup...")
+ enqueueArchive(false)
+ }
+ .withPermanentDenialDialog(
+ fragment.getString(R.string.BackupsPreferenceFragment_signal_requires_external_storage_permission_in_order_to_create_backups)
+ )
+ .execute()
+ }
+ }
+
+ override fun onTurnOnClick() {
+ if (BackupUtil.isUserSelectionRequired(fragment.requireContext())) {
+ // When the user-selection flow is required, the screen shows a compose dialog and then
+ // triggers [launchBackupDirectoryPicker] via callback.
+ // This method intentionally does nothing in that case.
+ } else {
+ Permissions.with(fragment)
+ .request(Manifest.permission.WRITE_EXTERNAL_STORAGE)
+ .ifNecessary()
+ .onAllGranted {
+ onLaunchBackupLocationPickerClick()
+ }
+ .withPermanentDenialDialog(
+ fragment.getString(R.string.BackupsPreferenceFragment_signal_requires_external_storage_permission_in_order_to_create_backups)
+ )
+ .execute()
+ }
+ }
+
+ override fun onViewBackupKeyClick() {
+ fragment.findNavController().safeNavigate(R.id.action_backupsPreferenceFragment_to_backupKeyDisplayFragment)
+ }
+
+ override fun onLearnMoreClick() {
+ CommunicationActions.openBrowserLink(fragment.requireContext(), fragment.getString(R.string.backup_support_url))
+ }
+
+ override fun onTurnOffAndDeleteConfirmed() {
+ SignalStore.backup.newLocalBackupsEnabled = false
+
+ val path = SignalStore.backup.newLocalBackupsDirectory
+ SignalStore.backup.newLocalBackupsDirectory = null
+ AppDependencies.jobManager.cancelAllInQueue(LocalBackupJob.QUEUE)
+ BackupUtil.deleteUnifiedBackups(fragment.requireContext(), path)
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsSettingsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsSettingsScreen.kt
new file mode 100644
index 0000000000..f90388ce90
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsSettingsScreen.kt
@@ -0,0 +1,361 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+package org.thoughtcrime.securesms.components.settings.app.backups.local
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SnackbarHostState
+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.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.dimensionResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.res.vectorResource
+import androidx.compose.ui.text.LinkAnnotation
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.TextLinkStyles
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.withLink
+import androidx.compose.ui.unit.dp
+import org.signal.core.ui.compose.Buttons
+import org.signal.core.ui.compose.Dialogs
+import org.signal.core.ui.compose.Dividers
+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.Snackbars
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.components.compose.rememberBiometricsAuthentication
+import org.thoughtcrime.securesms.util.BackupUtil
+import org.signal.core.ui.R as CoreUiR
+import org.signal.core.ui.compose.DayNightPreviews as DayNightPreview
+
+@Composable
+internal fun LocalBackupsSettingsScreen(
+ state: LocalBackupsSettingsState,
+ callback: LocalBackupsSettingsCallback,
+ snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }
+) {
+ val context = LocalContext.current
+ var showChooseLocationDialog by rememberSaveable { mutableStateOf(false) }
+ var showTurnOffAndDeleteDialog by rememberSaveable { mutableStateOf(false) }
+
+ val learnMore = stringResource(id = R.string.BackupsPreferenceFragment__learn_more)
+ val restoreText = stringResource(id = R.string.OnDeviceBackupsScreen__to_restore_a_backup, learnMore).trim()
+ val learnMoreColor = MaterialTheme.colorScheme.primary
+
+ val restoreInfo = remember(restoreText, learnMore, learnMoreColor) {
+ buildAnnotatedString {
+ append(restoreText)
+ append(" ")
+
+ withLink(
+ LinkAnnotation.Clickable(
+ tag = "learn-more",
+ linkInteractionListener = { callback.onLearnMoreClick() },
+ styles = TextLinkStyles(style = SpanStyle(color = learnMoreColor))
+ )
+ ) {
+ append(learnMore)
+ }
+ }
+ }
+
+ val biometrics = rememberBiometricsAuthentication(
+ promptTitle = stringResource(R.string.RemoteBackupsSettingsFragment__unlock_to_view_backup_key),
+ educationSheetMessage = stringResource(R.string.RemoteBackupsSettingsFragment__to_view_your_key)
+ )
+
+ Scaffolds.Settings(
+ title = stringResource(id = R.string.RemoteBackupsSettingsFragment__on_device_backups),
+ navigationIcon = ImageVector.vectorResource(CoreUiR.drawable.symbol_arrow_start_24),
+ onNavigationClick = callback::onNavigationClick,
+ snackbarHost = {
+ Snackbars.Host(snackbarHostState)
+ }
+ ) { paddingValues ->
+ LazyColumn(
+ modifier = Modifier.padding(paddingValues)
+ ) {
+ if (!state.backupsEnabled) {
+ item {
+ Rows.TextRow(
+ text = {
+ Column {
+ Text(
+ text = stringResource(id = R.string.BackupsPreferenceFragment__backups_are_encrypted_with_a_passphrase),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ style = MaterialTheme.typography.bodyMedium
+ )
+
+ Buttons.MediumTonal(
+ onClick = {
+ // For the SAF-based flow, present an in-screen dialog before launching the picker.
+ if (BackupUtil.isUserSelectionRequired(context)) {
+ showChooseLocationDialog = true
+ } else {
+ callback.onTurnOnClick()
+ }
+ },
+ enabled = state.canTurnOn,
+ modifier = Modifier.padding(top = 12.dp)
+ ) {
+ Text(text = stringResource(id = R.string.BackupsPreferenceFragment__turn_on))
+ }
+ }
+ }
+ )
+ }
+ } else {
+ val isCreating = state.progress is BackupProgressState.InProgress
+
+ item {
+ Rows.TextRow(
+ text = {
+ Column {
+ Text(
+ text = stringResource(id = R.string.BackupsPreferenceFragment__create_backup),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+
+ if (state.progress is BackupProgressState.InProgress) {
+ Text(
+ text = state.progress.summary,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 4.dp)
+ )
+
+ if (state.progress.progressFraction == null) {
+ LinearProgressIndicator(
+ trackColor = MaterialTheme.colorScheme.secondaryContainer,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 8.dp)
+ )
+ } else {
+ LinearProgressIndicator(
+ trackColor = MaterialTheme.colorScheme.secondaryContainer,
+ progress = { state.progress.progressFraction },
+ drawStopIndicator = {},
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 8.dp)
+ )
+ }
+
+ Text(
+ text = state.progress.percentLabel,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 8.dp)
+ )
+ } else {
+ Text(
+ text = state.lastBackupLabel.orEmpty(),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 4.dp)
+ )
+ }
+ }
+ },
+ enabled = !isCreating,
+ onClick = callback::onCreateBackupClick
+ )
+ }
+
+ item {
+ Rows.TextRow(
+ text = stringResource(id = R.string.BackupsPreferenceFragment__backup_time),
+ label = state.scheduleTimeLabel,
+ onClick = callback::onPickTimeClick
+ )
+ }
+
+ if (!state.folderDisplayName.isNullOrBlank()) {
+ item {
+ Rows.TextRow(
+ text = stringResource(id = R.string.BackupsPreferenceFragment__backup_folder),
+ label = state.folderDisplayName
+ )
+ }
+ }
+
+ item {
+ Rows.TextRow(
+ text = stringResource(id = R.string.UnifiedOnDeviceBackupsSettingsScreen__view_backup_key),
+ onClick = { biometrics.withBiometricsAuthentication { callback.onViewBackupKeyClick() } }
+ )
+ }
+ }
+
+ if (state.backupsEnabled) {
+ item {
+ Rows.TextRow(
+ text = stringResource(id = R.string.RemoteBackupsSettingsFragment__turn_off_and_delete),
+ foregroundTint = MaterialTheme.colorScheme.error,
+ onClick = { showTurnOffAndDeleteDialog = true }
+ )
+ }
+ }
+
+ item {
+ Dividers.Default()
+ }
+
+ item {
+ Text(
+ text = restoreInfo,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(
+ horizontal = dimensionResource(id = org.signal.core.ui.R.dimen.gutter),
+ vertical = 16.dp
+ )
+ )
+ }
+ }
+ }
+
+ if (showChooseLocationDialog) {
+ Dialogs.SimpleAlertDialog(
+ title = stringResource(id = R.string.BackupDialog_enable_local_backups),
+ body = stringResource(id = R.string.BackupDialog_to_enable_backups_choose_a_folder),
+ confirm = stringResource(id = R.string.BackupDialog_choose_folder),
+ dismiss = stringResource(id = android.R.string.cancel),
+ onConfirm = callback::onLaunchBackupLocationPickerClick,
+ onDismiss = { showChooseLocationDialog = false }
+ )
+ }
+
+ if (showTurnOffAndDeleteDialog) {
+ Dialogs.SimpleAlertDialog(
+ title = stringResource(id = R.string.BackupDialog_delete_backups),
+ body = stringResource(id = R.string.BackupDialog_disable_and_delete_all_local_backups),
+ confirm = stringResource(id = R.string.BackupDialog_delete_backups_statement),
+ confirmColor = MaterialTheme.colorScheme.error,
+ dismiss = stringResource(id = android.R.string.cancel),
+ onConfirm = callback::onTurnOffAndDeleteConfirmed,
+ onDismiss = { showTurnOffAndDeleteDialog = false }
+ )
+ }
+}
+
+@DayNightPreview
+@Composable
+private fun OnDeviceBackupsDisabledCanTurnOnPreviewSettings() {
+ Previews.Preview {
+ LocalBackupsSettingsScreen(
+ state = LocalBackupsSettingsState(
+ backupsEnabled = false,
+ canTurnOn = true
+ ),
+ callback = LocalBackupsSettingsCallback.Empty
+ )
+ }
+}
+
+@DayNightPreview
+@Composable
+private fun OnDeviceBackupsDisabledCannotTurnOnPreviewSettings() {
+ Previews.Preview {
+ LocalBackupsSettingsScreen(
+ state = LocalBackupsSettingsState(
+ backupsEnabled = false,
+ canTurnOn = false
+ ),
+ callback = LocalBackupsSettingsCallback.Empty
+ )
+ }
+}
+
+@DayNightPreview
+@Composable
+private fun LocalBackupsSettingsEnabledIdlePreview() {
+ Previews.Preview {
+ LocalBackupsSettingsScreen(
+ state = LocalBackupsSettingsState(
+ backupsEnabled = true,
+ lastBackupLabel = "Last backup: 1 hour ago",
+ folderDisplayName = "/storage/emulated/0/Signal/Backups",
+ scheduleTimeLabel = "1:00 AM",
+ progress = BackupProgressState.Idle
+ ),
+ callback = LocalBackupsSettingsCallback.Empty
+ )
+ }
+}
+
+@DayNightPreview
+@Composable
+private fun LocalBackupsSettingsEnabledInProgressIndeterminatePreview() {
+ Previews.Preview {
+ LocalBackupsSettingsScreen(
+ state = LocalBackupsSettingsState(
+ backupsEnabled = true,
+ lastBackupLabel = "Last backup: 1 hour ago",
+ folderDisplayName = "/storage/emulated/0/Signal/Backups",
+ scheduleTimeLabel = "1:00 AM",
+ progress = BackupProgressState.InProgress(
+ summary = "In progress…",
+ percentLabel = "123 so far…",
+ progressFraction = null
+ )
+ ),
+ callback = LocalBackupsSettingsCallback.Empty
+ )
+ }
+}
+
+@DayNightPreview
+@Composable
+private fun LocalBackupsSettingsEnabledInProgressPercentPreview() {
+ Previews.Preview {
+ LocalBackupsSettingsScreen(
+ state = LocalBackupsSettingsState(
+ backupsEnabled = true,
+ lastBackupLabel = "Last backup: 1 hour ago",
+ folderDisplayName = "/storage/emulated/0/Signal/Backups",
+ scheduleTimeLabel = "1:00 AM",
+ progress = BackupProgressState.InProgress(
+ summary = "In progress…",
+ percentLabel = "42.0% so far…",
+ progressFraction = 0.42f
+ )
+ ),
+ callback = LocalBackupsSettingsCallback.Empty
+ )
+ }
+}
+
+@DayNightPreview
+@Composable
+private fun LocalBackupsSettingsEnabledNonLegacyPreview() {
+ Previews.Preview {
+ LocalBackupsSettingsScreen(
+ state = LocalBackupsSettingsState(
+ backupsEnabled = true,
+ lastBackupLabel = "Last backup: 1 hour ago",
+ folderDisplayName = "Signal Backups",
+ scheduleTimeLabel = "1:00 AM",
+ progress = BackupProgressState.Idle
+ ),
+ callback = LocalBackupsSettingsCallback.Empty
+ )
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsSettingsState.kt
new file mode 100644
index 0000000000..d081677df6
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsSettingsState.kt
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+package org.thoughtcrime.securesms.components.settings.app.backups.local
+
+/**
+ * Immutable state for the on-device (legacy) backups settings screen.
+ *
+ * This is intended to be the single source of truth for UI rendering (i.e. a single `StateFlow`
+ * emission fully describes what the screen should display).
+ */
+data class LocalBackupsSettingsState(
+ val backupsEnabled: Boolean = false,
+ val canTurnOn: Boolean = true,
+ val lastBackupLabel: String? = null,
+ val folderDisplayName: String? = null,
+ val scheduleTimeLabel: String? = null,
+ val progress: BackupProgressState = BackupProgressState.Idle
+)
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
new file mode 100644
index 0000000000..892c91ae17
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsViewModel.kt
@@ -0,0 +1,191 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.components.settings.app.backups.local
+
+import android.content.Context
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.update
+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.util.logging.Log
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.backup.BackupPassphrase
+import org.thoughtcrime.securesms.backup.v2.LocalBackupV2Event
+import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeyCredentialManagerHandler
+import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeySaveState
+import org.thoughtcrime.securesms.dependencies.AppDependencies
+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
+import java.time.LocalTime
+import java.util.Locale
+
+/**
+ * Unified data model backups. Shares the same schema and file breakout as remote backups/.
+ */
+class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
+
+ companion object {
+ private val TAG = Log.tag(LocalBackupsViewModel::class)
+ }
+
+ private val formatter: NumberFormat = NumberFormat.getInstance().apply {
+ minimumFractionDigits = 1
+ maximumFractionDigits = 1
+ }
+
+ private val internalSettingsState = MutableStateFlow(
+ LocalBackupsSettingsState(
+ backupsEnabled = SignalStore.backup.newLocalBackupsEnabled,
+ folderDisplayName = SignalStore.backup.newLocalBackupsDirectory
+ )
+ )
+
+ private val internalBackupState = MutableStateFlow(LocalBackupsKeyState())
+
+ val settingsState = internalSettingsState
+ val backupState = internalBackupState
+
+ init {
+ val applicationContext = AppDependencies.application
+
+ viewModelScope.launch {
+ SignalStore.backup.newLocalBackupsEnabledFlow.collect { enabled ->
+ internalSettingsState.update { it.copy(backupsEnabled = enabled) }
+ }
+ }
+
+ viewModelScope.launch {
+ SignalStore.backup.newLocalBackupsDirectoryFlow.collect { directory ->
+ internalSettingsState.update { it.copy(folderDisplayName = directory) }
+ }
+ }
+
+ viewModelScope.launch {
+ SignalStore.backup.newLocalBackupsLastBackupTimeFlow.collect { lastBackupTime ->
+ internalSettingsState.update { it.copy(lastBackupLabel = calculateLastBackupTimeString(applicationContext, lastBackupTime)) }
+ }
+ }
+
+ EventBus.getDefault().register(this)
+ }
+
+ override fun onCleared() {
+ EventBus.getDefault().unregister(this)
+ }
+
+ fun refreshSettingsState() {
+ val context = AppDependencies.application
+ val backupTime = LocalTime.of(SignalStore.settings.backupHour, SignalStore.settings.backupMinute).formatHours(context)
+
+ val userUnregistered = TextSecurePreferences.isUnauthorizedReceived(context) || !SignalStore.account.isRegistered
+ 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)) {
+ Log.w(TAG, "Lost access to backup directory, disabling backups")
+ SignalStore.backup.newLocalBackupsEnabled = false
+ AppDependencies.jobManager.cancelAllInQueue(LocalBackupJob.QUEUE)
+ }
+ } else {
+ AppDependencies.jobManager.cancelAllInQueue(LocalBackupJob.QUEUE)
+ }
+
+ internalSettingsState.update {
+ it.copy(
+ canTurnOn = canTurnOn,
+ scheduleTimeLabel = backupTime
+ )
+ }
+ }
+
+ @Subscribe(threadMode = ThreadMode.MAIN)
+ fun onBackupEvent(event: LocalBackupV2Event) {
+ val context = AppDependencies.application
+ when (event.type) {
+ LocalBackupV2Event.Type.FINISHED -> {
+ internalSettingsState.update { it.copy(progress = BackupProgressState.Idle) }
+ }
+
+ else -> {
+ val summary = context.getString(R.string.BackupsPreferenceFragment__in_progress)
+ val progressState = if (event.estimatedTotalCount == 0L) {
+ BackupProgressState.InProgress(
+ summary = summary,
+ percentLabel = context.getString(R.string.BackupsPreferenceFragment__d_so_far, event.count),
+ progressFraction = null
+ )
+ } else {
+ val fraction = ((event.count / event.estimatedTotalCount.toDouble()) / 100.0).toFloat().coerceIn(0f, 1f)
+ BackupProgressState.InProgress(
+ summary = summary,
+ percentLabel = context.getString(R.string.BackupsPreferenceFragment__s_so_far, formatter.format((event.count / event.estimatedTotalCount.toDouble()))),
+ progressFraction = fraction
+ )
+ }
+
+ internalSettingsState.update { it.copy(progress = progressState) }
+ }
+ }
+ }
+
+ override fun updateBackupKeySaveState(newState: BackupKeySaveState?) {
+ internalBackupState.update { it.copy(keySaveState = newState) }
+ }
+
+ suspend fun handleUpgrade(context: Context) {
+ if (SignalStore.settings.isBackupEnabled) {
+ withContext(Dispatchers.IO) {
+ AppDependencies.jobManager.cancelAllInQueue(LocalBackupJob.QUEUE)
+ AppDependencies.jobManager.flush()
+ }
+
+ SignalStore.backup.newLocalBackupsDirectory = SignalStore.settings.signalBackupDirectory?.toString()
+
+ BackupPassphrase.set(context, null)
+ SignalStore.settings.isBackupEnabled = false
+ BackupUtil.deleteAllBackups()
+ }
+
+ SignalStore.backup.newLocalBackupsEnabled = true
+ LocalBackupJob.enqueueArchive(false)
+ }
+}
+
+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)
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/BackupKeyDisplayFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/BackupKeyDisplayFragment.kt
index 0ca33cb21b..766b755e25 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/BackupKeyDisplayFragment.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/BackupKeyDisplayFragment.kt
@@ -28,8 +28,6 @@ import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsKeyRec
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsKeyVerifyScreen
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.compose.Nav
-import org.thoughtcrime.securesms.util.Util
-import org.thoughtcrime.securesms.util.storage.AndroidCredentialRepository
import org.thoughtcrime.securesms.util.viewModel
/**
@@ -39,7 +37,6 @@ class BackupKeyDisplayFragment : ComposeFragment() {
companion object {
const val AEP_ROTATION_KEY = "AEP_ROTATION_KEY"
- const val CLIPBOARD_TIMEOUT_SECONDS = 60
}
private val viewModel: BackupKeyDisplayViewModel by viewModel { BackupKeyDisplayViewModel() }
@@ -48,7 +45,6 @@ class BackupKeyDisplayFragment : ComposeFragment() {
@Composable
override fun FragmentContent() {
val state by viewModel.uiState.collectAsStateWithLifecycle()
- val passwordManagerSettingsIntent = AndroidCredentialRepository.getCredentialManagerSettingsIntent(requireContext())
val navController = rememberNavController()
LaunchedEffect(Unit) {
@@ -121,14 +117,8 @@ class BackupKeyDisplayFragment : ComposeFragment() {
MessageBackupsKeyRecordScreen(
backupKey = state.accountEntropyPool.displayValue,
keySaveState = state.keySaveState,
- canOpenPasswordManagerSettings = passwordManagerSettingsIntent != null,
- onNavigationClick = { onBackPressedDispatcher?.onBackPressed() },
- onCopyToClipboardClick = { Util.copyToClipboard(requireContext(), it, CLIPBOARD_TIMEOUT_SECONDS) },
- onRequestSaveToPasswordManager = viewModel::onBackupKeySaveRequested,
- onConfirmSaveToPasswordManager = viewModel::onBackupKeySaveConfirmed,
- onSaveToPasswordManagerComplete = viewModel::onBackupKeySaveCompleted,
- mode = mode,
- onGoToPasswordManagerSettingsClick = { requireContext().startActivity(passwordManagerSettingsIntent) }
+ backupKeyCredentialManagerHandler = viewModel,
+ mode = mode
)
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsFragment.kt
index f0f169f2fd..1c5bed68d4 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsFragment.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsFragment.kt
@@ -9,8 +9,6 @@ import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
-import androidx.biometric.BiometricManager
-import androidx.biometric.BiometricPrompt
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
@@ -86,12 +84,8 @@ import org.signal.core.ui.compose.horizontalGutters
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.bytes
import org.signal.core.util.gibiBytes
-import org.signal.core.util.logging.Log
import org.signal.core.util.mebiBytes
import org.signal.core.util.money.FiatMoney
-import org.thoughtcrime.securesms.BiometricDeviceAuthentication
-import org.thoughtcrime.securesms.BiometricDeviceLockContract
-import org.thoughtcrime.securesms.DevicePinAuthEducationSheet
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.ArchiveUploadProgress
import org.thoughtcrime.securesms.backup.DeletionState
@@ -107,6 +101,8 @@ import org.thoughtcrime.securesms.backup.v2.ui.status.RestoreType
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
import org.thoughtcrime.securesms.billing.launchManageBackupsSubscription
import org.thoughtcrime.securesms.components.compose.BetaHeader
+import org.thoughtcrime.securesms.components.compose.BiometricsAuthentication
+import org.thoughtcrime.securesms.components.compose.rememberBiometricsAuthentication
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.backups.BackupState
import org.thoughtcrime.securesms.components.settings.app.subscription.MessageBackupsCheckoutLauncher.createBackupsCheckoutLauncher
@@ -135,10 +131,6 @@ import org.signal.core.ui.R as CoreUiR
*/
class RemoteBackupsSettingsFragment : ComposeFragment() {
- companion object {
- private val TAG = Log.tag(RemoteBackupsSettingsFragment::class)
- }
-
private val viewModel by viewModel {
RemoteBackupsSettingsViewModel()
}
@@ -146,8 +138,6 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
private val args: RemoteBackupsSettingsFragmentArgs by navArgs()
private lateinit var checkoutLauncher: ActivityResultLauncher
- private lateinit var biometricDeviceAuthentication: BiometricDeviceAuthentication
- private lateinit var biometricFallbackLauncher: ActivityResultLauncher
@Composable
override fun FragmentContent() {
@@ -213,16 +203,7 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
}
override fun onViewBackupKeyClick() {
- if (biometricDeviceAuthentication.shouldShowEducationSheet(requireContext())) {
- DevicePinAuthEducationSheet.show(getString(R.string.RemoteBackupsSettingsFragment__to_view_your_key), parentFragmentManager)
- parentFragmentManager.setFragmentResultListener(DevicePinAuthEducationSheet.REQUEST_KEY, viewLifecycleOwner) { _, _ ->
- if (!biometricDeviceAuthentication.authenticate(requireContext(), true, this@RemoteBackupsSettingsFragment::showConfirmDeviceCredentialIntent)) {
- displayBackupKey()
- }
- }
- } else if (!biometricDeviceAuthentication.authenticate(requireContext(), true, this@RemoteBackupsSettingsFragment::showConfirmDeviceCredentialIntent)) {
- displayBackupKey()
- }
+ displayBackupKey()
}
override fun onStartMediaRestore() {
@@ -308,10 +289,6 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
findNavController().safeNavigate(R.id.action_remoteBackupsSettingsFragment_to_backupKeyDisplayFragment)
}
- private fun showConfirmDeviceCredentialIntent() {
- biometricFallbackLauncher.launch(getString(R.string.RemoteBackupsSettingsFragment__unlock_to_view_backup_key))
- }
-
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
checkoutLauncher = createBackupsCheckoutLauncher { backUpLater ->
@@ -320,15 +297,6 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
}
}
- biometricFallbackLauncher = registerForActivityResult(
- contract = BiometricDeviceLockContract(),
- callback = { result ->
- if (result == BiometricDeviceAuthentication.AUTHENTICATED) {
- displayBackupKey()
- }
- }
- )
-
setFragmentResultListener(BackupKeyDisplayFragment.AEP_ROTATION_KEY) { _, bundle ->
val didRotate = bundle.getBoolean(BackupKeyDisplayFragment.AEP_ROTATION_KEY, false)
if (didRotate) {
@@ -340,38 +308,12 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
if (savedInstanceState == null && args.backupLaterSelected) {
viewModel.requestSnackbar(RemoteBackupsSettingsState.Snackbar.BACKUP_WILL_BE_CREATED_OVERNIGHT)
}
-
- val biometricManager = BiometricManager.from(requireContext())
- val biometricPrompt = BiometricPrompt(this, AuthListener())
- val promptInfo: BiometricPrompt.PromptInfo = BiometricPrompt.PromptInfo.Builder()
- .setAllowedAuthenticators(BiometricDeviceAuthentication.ALLOWED_AUTHENTICATORS)
- .setTitle(getString(R.string.RemoteBackupsSettingsFragment__unlock_to_view_backup_key))
- .build()
-
- biometricDeviceAuthentication = BiometricDeviceAuthentication(biometricManager, biometricPrompt, promptInfo)
}
override fun onResume() {
super.onResume()
viewModel.refresh()
}
-
- private inner class AuthListener : BiometricPrompt.AuthenticationCallback() {
- override fun onAuthenticationFailed() {
- Log.w(TAG, "onAuthenticationFailed")
- Toast.makeText(requireContext(), R.string.RemoteBackupsSettingsFragment__authenticatino_required, Toast.LENGTH_SHORT).show()
- }
-
- override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
- Log.i(TAG, "onAuthenticationSucceeded")
- displayBackupKey()
- }
-
- override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
- Log.w(TAG, "onAuthenticationError: $errorCode, $errString")
- onAuthenticationFailed()
- }
- }
}
/**
@@ -422,6 +364,15 @@ private fun RemoteBackupsSettingsContent(
backupProgress: ArchiveUploadProgressState?,
statusBarColorNestedScrollConnection: StatusBarColorNestedScrollConnection?
) {
+ val context = LocalContext.current
+ val biometrics = rememberBiometricsAuthentication(
+ promptTitle = stringResource(R.string.RemoteBackupsSettingsFragment__unlock_to_view_backup_key),
+ educationSheetMessage = stringResource(R.string.RemoteBackupsSettingsFragment__to_view_your_key),
+ onAuthenticationFailed = {
+ Toast.makeText(context, R.string.RemoteBackupsSettingsFragment__authenticatino_required, Toast.LENGTH_SHORT).show()
+ }
+ )
+
val snackbarHostState = remember {
SnackbarHostState()
}
@@ -545,7 +496,8 @@ private fun RemoteBackupsSettingsContent(
state = state,
backupRestoreState = backupRestoreState,
backupProgress = backupProgress,
- contentCallbacks = contentCallbacks
+ contentCallbacks = contentCallbacks,
+ biometrics = biometrics
)
} else {
if (state.backupCreationError != null) {
@@ -883,7 +835,8 @@ private fun LazyListScope.appendBackupDetailsItems(
state: RemoteBackupsSettingsState,
backupRestoreState: BackupRestoreState,
backupProgress: ArchiveUploadProgressState?,
- contentCallbacks: ContentCallbacks
+ contentCallbacks: ContentCallbacks,
+ biometrics: BiometricsAuthentication
) {
item {
Dividers.Default()
@@ -984,7 +937,7 @@ private fun LazyListScope.appendBackupDetailsItems(
item {
Rows.TextRow(
text = stringResource(R.string.RemoteBackupsSettingsFragment__view_backup_key),
- onClick = contentCallbacks::onViewBackupKeyClick,
+ onClick = { biometrics.withBiometricsAuthentication { contentCallbacks.onViewBackupKeyClick() } },
enabled = state.canViewBackupKey
)
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsFragment.kt
index 0e1517170e..009f3c9990 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsFragment.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsFragment.kt
@@ -79,10 +79,6 @@ class ChatsSettingsFragment : ComposeFragment() {
override fun onEnterKeySendsChanged(enabled: Boolean) {
viewModel.setEnterKeySends(enabled)
}
-
- override fun onChatBackupsClick() {
- findNavController().safeNavigate(R.id.action_chatsSettingsFragment_to_backupsPreferenceFragment)
- }
}
}
@@ -95,7 +91,6 @@ private interface ChatsSettingsCallbacks {
fun onAddOrEditFoldersClick() = Unit
fun onUseSystemEmojiChanged(enabled: Boolean) = Unit
fun onEnterKeySendsChanged(enabled: Boolean) = Unit
- fun onChatBackupsClick() = Unit
object Empty : ChatsSettingsCallbacks
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/routes/AppSettingsRoute.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/routes/AppSettingsRoute.kt
index 05de684db9..846e12ea11 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/routes/AppSettingsRoute.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/routes/AppSettingsRoute.kt
@@ -64,7 +64,7 @@ sealed interface AppSettingsRoute : Parcelable {
@Parcelize
sealed interface BackupsRoute : AppSettingsRoute {
data object Backups : BackupsRoute
- data object Local : BackupsRoute
+ data class Local(val triggerUpdateFlow: Boolean = false) : BackupsRoute
data class Remote(val backupLaterSelected: Boolean = false, val forQuickRestore: Boolean = false) : BackupsRoute
data object DisplayKey : BackupsRoute
}
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 d232b5da06..536f7af09a 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java
@@ -131,6 +131,7 @@ public final class Megaphones {
put(Event.PNP_LAUNCH, shouldShowPnpLaunchMegaphone() ? ALWAYS : NEVER);
put(Event.TURN_ON_SIGNAL_BACKUPS, shouldShowTurnOnBackupsMegaphone(context) ? new RecurringSchedule(TimeUnit.DAYS.toMillis(30), TimeUnit.DAYS.toMillis(90)) : NEVER);
put(Event.VERIFY_BACKUP_KEY, new VerifyBackupKeyReminderSchedule());
+ put(Event.USE_NEW_ON_DEVICE_BACKUPS, shouldShowUseNewOnDeviceBackupsMegaphone() ? ALWAYS : NEVER);
}};
}
@@ -184,6 +185,8 @@ public final class Megaphones {
return buildTurnOnSignalBackupsMegaphone();
case VERIFY_BACKUP_KEY:
return buildVerifyBackupKeyMegaphone();
+ case USE_NEW_ON_DEVICE_BACKUPS:
+ return buildUseNewOnDeviceBackupsMegaphone();
default:
throw new IllegalArgumentException("Event not handled!");
}
@@ -491,6 +494,23 @@ public final class Megaphones {
return builder.build();
}
+ public static @NonNull Megaphone buildUseNewOnDeviceBackupsMegaphone() {
+ return new Megaphone.Builder(Event.USE_NEW_ON_DEVICE_BACKUPS, Megaphone.Style.BASIC)
+ .setImage(R.drawable.backups_megaphone_image)
+ .setTitle(R.string.UseNewOnDeviceBackups__title)
+ .setBody(R.string.UseNewOnDeviceBackups__body)
+ .setActionButton(R.string.UseNewOnDeviceBackups__upgrade, (megaphone, controller) -> {
+ Intent intent = AppSettingsActivity.upgradeLocalBackups(controller.getMegaphoneActivity());
+
+ controller.onMegaphoneNavigationRequested(intent);
+ controller.onMegaphoneSnooze(Event.USE_NEW_ON_DEVICE_BACKUPS);
+ })
+ .setSecondaryButton(R.string.UseNewOnDeviceBackups__not_now, (megaphone, controller) -> {
+ controller.onMegaphoneSnooze(Event.USE_NEW_ON_DEVICE_BACKUPS);
+ })
+ .build();
+ }
+
private static boolean shouldShowOnboardingMegaphone(@NonNull Context context) {
return SignalStore.account().isPrimaryDevice() && SignalStore.onboarding().hasOnboarding(context);
}
@@ -575,6 +595,10 @@ public final class Megaphones {
return VersionTracker.getDaysSinceFirstInstalled(context) > 7;
}
+ private static boolean shouldShowUseNewOnDeviceBackupsMegaphone() {
+ return RemoteConfig.unifiedLocalBackups() && SignalStore.settings().isBackupEnabled();
+ }
+
private static boolean shouldShowGrantFullScreenIntentPermission(@NonNull Context context) {
return Build.VERSION.SDK_INT >= 34 && !NotificationManagerCompat.from(context).canUseFullScreenIntent();
}
@@ -626,7 +650,8 @@ public final class Megaphones {
GRANT_FULL_SCREEN_INTENT("grant_full_screen_intent"),
NEW_LINKED_DEVICE("new_linked_device"),
TURN_ON_SIGNAL_BACKUPS("turn_on_signal_backups"),
- VERIFY_BACKUP_KEY("verify_backup_key");
+ VERIFY_BACKUP_KEY("verify_backup_key"),
+ USE_NEW_ON_DEVICE_BACKUPS("use_new_on_device_backups");
private final String key;
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 fd968a2645..0f3c8be073 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/preferences/BackupsPreferenceFragment.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/BackupsPreferenceFragment.java
@@ -18,6 +18,7 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.appcompat.widget.Toolbar;
+import androidx.compose.ui.platform.ComposeView;
import androidx.core.text.HtmlCompat;
import androidx.fragment.app.Fragment;
import androidx.navigation.Navigation;
@@ -38,9 +39,11 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies;
import org.thoughtcrime.securesms.jobs.LocalBackupJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.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.JavaTimeExtensionsKt;
+import org.thoughtcrime.securesms.util.RemoteConfig;
import org.thoughtcrime.securesms.util.StorageUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
@@ -50,6 +53,7 @@ import java.util.Locale;
import java.util.Objects;
import kotlin.Pair;
+import kotlin.Unit;
public class BackupsPreferenceFragment extends Fragment {
@@ -68,6 +72,7 @@ public class BackupsPreferenceFragment extends Fragment {
private TextView folderName;
private ProgressBar progress;
private TextView progressSummary;
+ private ComposeView upgradeCard;
private final NumberFormat formatter = NumberFormat.getInstance();
@@ -92,6 +97,7 @@ public class BackupsPreferenceFragment extends Fragment {
folderName = view.findViewById(R.id.fragment_backup_folder_name);
progress = view.findViewById(R.id.fragment_backup_progress);
progressSummary = view.findViewById(R.id.fragment_backup_progress_summary);
+ upgradeCard = view.findViewById(R.id.upgrade_to_improved_backups_card);
toggle.setOnClickListener(unused -> onToggleClicked());
create.setOnClickListener(unused -> onCreateClicked());
@@ -113,6 +119,7 @@ public class BackupsPreferenceFragment extends Fragment {
setBackupStatus();
setBackupSummary();
setInfo();
+ setUpdateState();
}
@Override
@@ -223,6 +230,24 @@ public class BackupsPreferenceFragment extends Fragment {
info.setMovementMethod(LinkMovementMethod.getInstance());
}
+ private void setUpdateState() {
+ if (SignalStore.settings().isBackupEnabled() && RemoteConfig.unifiedLocalBackups()) {
+ UpgradeLocalBackupCard.bind(upgradeCard, () -> {
+ Navigation.findNavController(requireView())
+ .navigate(BackupsPreferenceFragmentDirections.actionBackupsPreferenceFragmentToLocalBackupsFragment()
+ .setTriggerUpdateFlow(true));
+ return Unit.INSTANCE;
+ });
+ upgradeCard.setVisibility(View.VISIBLE);
+ } else {
+ upgradeCard.setVisibility(View.GONE);
+ }
+
+ if (SignalStore.backup().getNewLocalBackupsEnabled()) {
+ Navigation.findNavController(requireView()).popBackStack();
+ }
+ }
+
private void onToggleClicked() {
if (BackupUtil.isUserSelectionRequired(requireContext())) {
onToggleClickedApi29();
diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/UpgradeLocalBackupCard.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/UpgradeLocalBackupCard.kt
new file mode 100644
index 0000000000..6f8932bff3
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/UpgradeLocalBackupCard.kt
@@ -0,0 +1,84 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.preferences.widgets
+
+import androidx.compose.foundation.border
+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.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.ComposeView
+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.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.compose.SignalTheme
+
+object UpgradeLocalBackupCard {
+
+ @JvmStatic
+ fun ComposeView.bind(
+ onClick: () -> Unit
+ ) {
+ setContent {
+ SignalTheme {
+ UpgradeLocalBackupCardComponent(onClick = onClick)
+ }
+ }
+ }
+}
+
+@Composable
+private fun UpgradeLocalBackupCardComponent(onClick: () -> Unit) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp)
+ .padding(top = 8.dp, bottom = 16.dp)
+ .border(width = 1.dp, color = MaterialTheme.colorScheme.outline.copy(alpha = 0.38f), shape = RoundedCornerShape(12.dp))
+ .padding(16.dp)
+ ) {
+ Icon(
+ imageVector = ImageVector.vectorResource(R.drawable.symbol_key_24),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(end = 16.dp)
+ )
+
+ Text(
+ text = stringResource(R.string.OnDeviceBackupsSettingsScreen__update_to_a_new_recovery_key),
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(end = 8.dp).weight(1f)
+ )
+
+ Buttons.Small(
+ onClick = onClick
+ ) {
+ Text(
+ text = stringResource(R.string.OnDeviceBackupsSettingsScreen__update)
+ )
+ }
+ }
+}
+
+@DayNightPreviews
+@Composable
+private fun UpgradeLocalBackupCardComponentPreview() {
+ Previews.Preview {
+ UpgradeLocalBackupCardComponent(onClick = {})
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/BackupUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/BackupUtil.java
index 644a05852b..b7c3741d0f 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/util/BackupUtil.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/util/BackupUtil.java
@@ -17,6 +17,7 @@ import org.signal.core.util.logging.Log;
import org.signal.libsignal.protocol.util.ByteUtil;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.backup.BackupPassphrase;
+import org.thoughtcrime.securesms.backup.v2.local.ArchiveFileSystem;
import org.thoughtcrime.securesms.database.NoExternalStorageException;
import org.thoughtcrime.securesms.dependencies.AppDependencies;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
@@ -69,6 +70,38 @@ public class BackupUtil {
}
}
+ public static boolean canUserAccessUnifiedBackupDirectory(@NonNull Context context) {
+ if (isUserSelectionRequired(context)) {
+ Uri backupDirectoryUri = Uri.parse(SignalStore.backup().getNewLocalBackupsDirectory());
+ if (backupDirectoryUri == null) {
+ return false;
+ }
+
+ DocumentFile backupDirectory = DocumentFile.fromTreeUri(context, backupDirectoryUri);
+ return backupDirectory != null && backupDirectory.exists() && backupDirectory.canRead() && backupDirectory.canWrite();
+ } else {
+ return Permissions.hasAll(context, Manifest.permission.WRITE_EXTERNAL_STORAGE);
+ }
+ }
+
+ public static void deleteUnifiedBackups(@NonNull Context context, @Nullable String backupDirectoryPath) {
+ if (backupDirectoryPath != null) {
+ Uri backupDirectoryUri = Uri.parse(backupDirectoryPath);
+ DocumentFile backupDirectory = DocumentFile.fromTreeUri(context, backupDirectoryUri);
+
+ if (backupDirectory == null || !backupDirectory.exists() || !backupDirectory.canRead() || !backupDirectory.canWrite()) {
+ Log.w(TAG, "Backup directory is inaccessible. Cannot delete backups.");
+ return;
+ }
+
+ for (DocumentFile file : backupDirectory.listFiles()) {
+ if (file.isDirectory() && Objects.equals(file.getName(), ArchiveFileSystem.MAIN_DIRECTORY_NAME)) {
+ file.delete();
+ }
+ }
+ }
+ }
+
public static @Nullable BackupInfo getLatestBackup() throws NoExternalStorageException {
List backups = getAllBackupsNewestFirst();
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 9d960bfbff..2fe9d9bcdd 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt
@@ -1238,5 +1238,16 @@ 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
+ )
+
// endregion
}
diff --git a/app/src/main/res/layout/fragment_backups.xml b/app/src/main/res/layout/fragment_backups.xml
index 62f2ca53e2..ebdd02feae 100644
--- a/app/src/main/res/layout/fragment_backups.xml
+++ b/app/src/main/res/layout/fragment_backups.xml
@@ -1,10 +1,10 @@
+ android:layout_height="match_parent"
+ tools:viewBindingIgnore="true">
+
+
-
+ tools:layout="@layout/fragment_backups">
+
+
+
+
+
+
+
+
+
+
+ app:popExitAnim="@anim/fragment_close_exit">
+
+
+
+
+
+
+
+
+
+
Backup time
Verify backup passphrase
Test your backup passphrase and verify that it matches
- Turn on
+ Turn on backups
Turn off
To restore a backup, install a new copy of Signal. Open the app and tap "Restore backup", then locate a backup file. %1$s
Learn more
@@ -7890,6 +7890,18 @@
You can enable backups in \"Settings\"
+
+ Use new on-device backups
+
+ On-device backups now save faster and use less data.
+
+ Upgrade
+
+ Not now
+
+
+ To restore a backup, install a new copy of Signal. Open the app and tap "Restore backup", then locate a backup file.
+
Verify your recovery key
@@ -8109,6 +8121,12 @@
Have too many devices using the same subscription.
+
+
+ Backup key updated
+
+ Directory selected: %1$s
+
Free up %1$s of space to restore your media.
@@ -8549,6 +8567,29 @@
Scan QR code
+
+
+ Improvements to on-device backups
+
+ Your on-device backup will be upgraded to a new format using a new recovery key
+
+ Backups now save faster and use less data
+
+ Your backup will be saved as a folder with many files
+
+ All backups remain end-to-end encrypted
+
+ Continue
+
+
+
+ Update to a new recovery key for faster backup saves
+
+ Update
+
+
+ View backup key
+
Download your backup today
@@ -8602,12 +8643,32 @@
Recovery key
+
+ Your new recovery key
+
+ Your recovery key
Your recovery key is a 64-character code that you will need to restore your backup.
Store your recovery key somewhere safe like a secure password manager, and don’t share it with anyone.
If you lose it, you won’t be able to recover your messages.
+
+ Your recovery key is a 64-digit code that lets you restore all backup types.
+
+ This key will replace the key for your on-device backup.
+
+ Your backup key is a 64-digit code that lets you restore your backups when you re-install Signal.
+
+ This is the same as your on-device backup key.
+
+ Use this key to:
+
+ Restore your on-device backup
+
+ Restore a Signal Secure Backup
+
+ Restore your Signal Secure Backup
View recovery key
@@ -8616,6 +8677,8 @@
Record your recovery key
This key is required to recover your account and data. Store this key somewhere safe. If you lose it, you won’t be able to recover your account.
+
+ This key is the same as your on-device backup key. It is required to recover your account and data.
Copy to clipboard