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
}