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