mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-15 07:28:30 +00:00
Local backups upgrade UI.
This commit is contained in:
committed by
Greyson Parrelli
parent
2e70ed14dd
commit
6c30f3d573
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = {})
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 don’t share it with anyone.</string>
|
||||
<!-- Screen body part 3 -->
|
||||
<string name="MessageBackupsKeyEducationScreen__if_you_lose_it">If you lose it, you won’t 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 won’t 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. -->
|
||||
|
||||
Reference in New Issue
Block a user