Local backups upgrade UI.

This commit is contained in:
Alex Hart
2026-01-30 10:13:36 -04:00
committed by Greyson Parrelli
parent 2e70ed14dd
commit 6c30f3d573
30 changed files with 2007 additions and 202 deletions

View File

@@ -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)
}

View File

@@ -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
)
}

View File

@@ -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
)
}
}

View File

@@ -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() {

View File

@@ -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) }
}
)
}
}

View File

@@ -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<String>
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

View File

@@ -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<Fragment>() }.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
)
}

View File

@@ -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)

View File

@@ -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
)

View File

@@ -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()
}

View File

@@ -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<LocalBackupsViewModel>()
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<NavKey>): ActivityResultLauncher<Intent> {
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")
}
}
}

View File

@@ -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()
}
}

View File

@@ -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
)

View File

@@ -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
}

View File

@@ -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<Intent>,
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)
}
}

View File

@@ -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
)
}
}

View File

@@ -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
)

View File

@@ -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)
}
}

View File

@@ -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
)
}

View File

@@ -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<MessageBackupTier?>
private lateinit var biometricDeviceAuthentication: BiometricDeviceAuthentication
private lateinit var biometricFallbackLauncher: ActivityResultLauncher<String>
@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
)
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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;

View File

@@ -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();

View File

@@ -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 = {})
}
}

View File

@@ -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<BackupInfo> backups = getAllBackupsNewestFirst();

View File

@@ -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
}

View File

@@ -1,10 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
tools:viewBindingIgnore="true"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_height="match_parent"
tools:viewBindingIgnore="true">
<LinearLayout
android:layout_width="match_parent"
@@ -21,6 +21,11 @@
app:navigationIcon="@drawable/symbol_arrow_start_24"
app:title="@string/BackupsPreferenceFragment__chat_backups" />
<androidx.compose.ui.platform.ComposeView
android:id="@+id/upgrade_to_improved_backups_card"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"

View File

@@ -377,13 +377,6 @@
android:name="org.thoughtcrime.securesms.components.settings.app.chats.ChatsSettingsFragment"
android:label="chats_settings_fragment"
tools:layout="@layout/dsl_settings_fragment">
<action
android:id="@+id/action_chatsSettingsFragment_to_backupsPreferenceFragment"
app:destination="@id/backupsPreferenceFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_chatsSettingsFragment_to_editReactionsFragment"
app:destination="@id/editReactionsFragment"
@@ -411,7 +404,35 @@
android:id="@+id/backupsPreferenceFragment"
android:name="org.thoughtcrime.securesms.preferences.BackupsPreferenceFragment"
android:label="backups_preference_fragment"
tools:layout="@layout/fragment_backups" />
tools:layout="@layout/fragment_backups">
<action
android:id="@+id/action_backupsPreferenceFragment_to_localBackupsFragment"
app:destination="@id/localBackupsFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
</fragment>
<fragment
android:id="@+id/localBackupsFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.backups.local.LocalBackupsFragment"
android:label="backups_preference_fragment">
<action
android:id="@+id/action_backupsPreferenceFragment_to_backupKeyDisplayFragment"
app:destination="@id/backupKeyDisplayFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<argument
android:name="trigger_update_flow"
app:argType="boolean"
android:defaultValue="false" />
</fragment>
<fragment
android:id="@+id/editReactionsFragment"
@@ -600,7 +621,27 @@
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
app:popExitAnim="@anim/fragment_close_exit">
<argument
android:name="trigger_update_flow"
app:argType="boolean"
android:defaultValue="false" />
</action>
<action
android:id="@+id/action_direct_to_localBackupsFragment"
app:destination="@id/localBackupsFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit">
<argument
android:name="trigger_update_flow"
app:argType="boolean"
android:defaultValue="false" />
</action>
<action
android:id="@+id/action_direct_to_helpFragment"
@@ -1153,6 +1194,14 @@
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_backupsSettingsFragment_to_localBackupsFragment"
app:destination="@id/localBackupsFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_backupsSettingsFragment_to_remoteBackupsSettingsFragment"
app:destination="@id/remoteBackupsSettingsFragment"

View File

@@ -956,7 +956,7 @@
<string name="BackupsPreferenceFragment__backup_time">Backup time</string>
<string name="BackupsPreferenceFragment__verify_backup_passphrase">Verify backup passphrase</string>
<string name="BackupsPreferenceFragment__test_your_backup_passphrase">Test your backup passphrase and verify that it matches</string>
<string name="BackupsPreferenceFragment__turn_on">Turn on</string>
<string name="BackupsPreferenceFragment__turn_on">Turn on backups</string>
<string name="BackupsPreferenceFragment__turn_off">Turn off</string>
<string name="BackupsPreferenceFragment__to_restore_a_backup">To restore a backup, install a new copy of Signal. Open the app and tap "Restore backup", then locate a backup file. %1$s</string>
<string name="BackupsPreferenceFragment__learn_more">Learn more</string>
@@ -7890,6 +7890,18 @@
<!-- Text for a "toast" that pops up at the bottom of the user\'s screen after they respond "not now" to a prompt asking them to enable backups -->
<string name="TurnOnSignalBackups__toast_not_now">You can enable backups in \"Settings\"</string>
<!-- Title of a megaphone shown to prompt the user to upgrade to the new local backups type -->
<string name="UseNewOnDeviceBackups__title">Use new on-device backups</string>
<!-- Body of a megaphone shown to prompt the user to upgrade to the new local backups type -->
<string name="UseNewOnDeviceBackups__body">On-device backups now save faster and use less data.</string>
<!-- Button of a megaphone that will take the user to the upgrade flow -->
<string name="UseNewOnDeviceBackups__upgrade">Upgrade</string>
<!-- Button of a megaphone that will snooze the reminder to upgrade to the new local backups type -->
<string name="UseNewOnDeviceBackups__not_now">Not now</string>
<!-- Text describing how to restore a backup displayed on the on-device backups screen -->
<string name="OnDeviceBackupsScreen__to_restore_a_backup">To restore a backup, install a new copy of Signal. Open the app and tap "Restore backup", then locate a backup file.</string>
<!-- Title of a megaphone shown to prompt the user to verify their recovery key -->
<string name="VerifyBackupKey__title">Verify your recovery key</string>
<!-- Body of a megaphone shown to prompt the user to verify their recovery key -->
@@ -8109,6 +8121,12 @@
<!-- Backup redemption error sheet bullet point 2 -->
<string name="BackupAlertBottomSheet__have_too_many_devices_using_the_same_subscription">Have too many devices using the same subscription.</string>
<!-- OnDeviceBackupsFragment -->
<!-- Snackbar message shown after successfully updating the on-device backups recovery key. -->
<string name="OnDeviceBackupsFragment__backup_key_updated">Backup key updated</string>
<!-- Toast message shown after selecting a folder to store on-device backups. Placeholder is the selected folder. -->
<string name="OnDeviceBackupsFragment__directory_selected">Directory selected: %1$s</string>
<!-- BackupStatus -->
<!-- Status title when user does not have enough free space to download their media. Placeholder is required disk space. -->
<string name="BackupStatus__free_up_s_of_space_to_download_your_media">Free up %1$s of space to restore your media.</string>
@@ -8549,6 +8567,29 @@
<!-- Button label to open QR scanner to scan a device and transfer a backup -->
<string name="RemoteBackupsSettingsFragment__scan_qr_code">Scan QR code</string>
<!-- OnDeviceBackupsImprovementsScreen -->
<!-- Title displayed at the top of the screen explaining on-device backup improvements -->
<string name="OnDeviceBackupsImprovementsScreen__improvements_to_on_device_backups">Improvements to on-device backups</string>
<!-- Subtitle describing that the backup will be upgraded to a new format with a new recovery key -->
<string name="OnDeviceBackupsImprovementsScreen__your_on_device_backup_will_be_upgraded">Your on-device backup will be upgraded to a new format using a new recovery key</string>
<!-- Feature description explaining backups are faster and use less data -->
<string name="OnDeviceBackupsImprovementsScreen__backups_now_save_faster">Backups now save faster and use less data</string>
<!-- Feature description explaining backups will be saved as a folder with many files -->
<string name="OnDeviceBackupsImprovementsScreen__your_backup_will_be_saved_as_a_folder">Your backup will be saved as a folder with many files</string>
<!-- Feature description explaining backups remain end-to-end encrypted -->
<string name="OnDeviceBackupsImprovementsScreen__all_backups_remain_end_to_end_encrypted">All backups remain end-to-end encrypted</string>
<!-- Button text to continue to the next step -->
<string name="OnDeviceBackupsImprovementsScreen__continue">Continue</string>
<!-- OnDeviceBackupsSettingsScreen -->
<!-- Card text prompting user to update to a new recovery key for faster backups -->
<string name="OnDeviceBackupsSettingsScreen__update_to_a_new_recovery_key">Update to a new recovery key for faster backup saves</string>
<!-- Button text to update to a new recovery key -->
<string name="OnDeviceBackupsSettingsScreen__update">Update</string>
<!-- UnifiedOnDeviceBackupsSettingsScreen -->
<string name="UnifiedOnDeviceBackupsSettingsScreen__view_backup_key">View backup key</string>
<!-- DownloadYourBackupTodayDialog -->
<!-- Dialog title -->
<string name="DownloadYourBackupTodayDialog__download_your_backup_today">Download your backup today</string>
@@ -8602,12 +8643,32 @@
<!-- MessageBackupsKeyEducationScreen -->
<!-- Screen headline -->
<string name="MessageBackupsKeyEducationScreen__your_backup_key">Recovery key</string>
<!-- Screen headline shown when upgrading on-device backups to a new recovery key. -->
<string name="MessageBackupsKeyEducationScreen__your_new_recovery_key">Your new recovery key</string>
<!-- Screen headline shown when the user already has an on-device recovery key set. -->
<string name="MessageBackupsKeyEducationScreen__your_recovery_key">Your recovery key</string>
<!-- Screen body part 1 -->
<string name="MessageBackupsKeyEducationScreen__your_backup_key_is_a">Your recovery key is a 64-character code that you will need to restore your backup.</string>
<!-- Screen body part 2 -->
<string name="MessageBackupsKeyEducationScreen__store_your_recovery">Store your recovery key somewhere safe like a secure password manager, and dont share it with anyone.</string>
<!-- Screen body part 3 -->
<string name="MessageBackupsKeyEducationScreen__if_you_lose_it">If you lose it, you wont be able to recover your messages.</string>
<!-- Screen body shown when upgrading on-device backups to a new recovery key (part 1). -->
<string name="MessageBackupsKeyEducationScreen__local_backup_upgrade_description">Your recovery key is a 64-digit code that lets you restore all backup types.</string>
<!-- Screen body shown when upgrading on-device backups to a new recovery key (part 2, bolded). -->
<string name="MessageBackupsKeyEducationScreen__local_backup_upgrade_description_bold">This key will replace the key for your on-device backup.</string>
<!-- Screen body shown when enabling Signal Secure Backups while on-device backups are already enabled (part 1). -->
<string name="MessageBackupsKeyEducationScreen__remote_backup_with_local_enabled_description">Your backup key is a 64-digit code that lets you restore your backups when you re-install Signal.</string>
<!-- Screen body shown when enabling Signal Secure Backups while on-device backups are already enabled (part 2, bolded). -->
<string name="MessageBackupsKeyEducationScreen__remote_backup_with_local_enabled_description_bold">This is the same as your on-device backup key.</string>
<!-- Label shown above a list of actions that the recovery key can be used for. -->
<string name="MessageBackupsKeyEducationScreen__use_this_key_to">Use this key to:</string>
<!-- Row label indicating that the recovery key can be used to restore an on-device backup. -->
<string name="MessageBackupsKeyEducationScreen__restore_on_device_backup">Restore your on-device backup</string>
<!-- Row label indicating that the recovery key can be used to restore a Signal Secure Backup. -->
<string name="MessageBackupsKeyEducationScreen__restore_a_signal_secure_backup">Restore a Signal Secure Backup</string>
<!-- Row label indicating that the recovery key can be used to restore a Signal Secure Backup. -->
<string name="MessageBackupsKeyEducationScreen__restore_your_signal_secure_backup">Restore your Signal Secure Backup</string>
<!-- Action button label -->
<string name="MessageBackupsKeyEducationScreen__view_recovery_key">View recovery key</string>
@@ -8616,6 +8677,8 @@
<string name="MessageBackupsKeyRecordScreen__record_your_backup_key">Record your recovery key</string>
<!-- Screen subhead -->
<string name="MessageBackupsKeyRecordScreen__this_key_is_required_to_recover">This key is required to recover your account and data. Store this key somewhere safe. If you lose it, you wont be able to recover your account.</string>
<!-- Screen subhead shown when the recovery key is shared between Signal Secure Backups and on-device backups. -->
<string name="MessageBackupsKeyRecordScreen__this_key_is_the_same_as_your_on_device_backup_key">This key is the same as your on-device backup key. It is required to recover your account and data.</string>
<!-- Copy to clipboard button label -->
<string name="MessageBackupsKeyRecordScreen__copy_to_clipboard">Copy to clipboard</string>
<!-- Label for the button to save a recovery key to the device password manager. -->