Local backups upgrade UI.

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

View File

@@ -0,0 +1,199 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.compose
import android.content.Context
import android.view.View
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.platform.LocalView
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.findFragment
import androidx.lifecycle.LifecycleOwner
import org.thoughtcrime.securesms.BiometricDeviceAuthentication
import org.thoughtcrime.securesms.BiometricDeviceLockContract
import org.thoughtcrime.securesms.DevicePinAuthEducationSheet
@Stable
class BiometricsAuthentication internal constructor(
private val authenticateImpl: (onAuthenticated: () -> Unit) -> Unit,
private val cancelImpl: () -> Unit
) {
fun withBiometricsAuthentication(onAuthenticated: () -> Unit) {
authenticateImpl(onAuthenticated)
}
fun cancelAuthentication() {
cancelImpl()
}
}
/**
* A lightweight helper for prompting the user for biometric/device-credential authentication from Compose.
*
* Intended usage:
*
* - `val biometrics = rememberBiometricsAuthentication(...)`
* - `onClick = { biometrics.withBiometricsAuthentication { performAction() } }`
*/
@Composable
fun rememberBiometricsAuthentication(
promptTitle: String? = null,
educationSheetMessage: String? = null,
onAuthenticationFailed: (() -> Unit)? = null
): BiometricsAuthentication {
if (LocalInspectionMode.current) {
return remember {
BiometricsAuthentication(
authenticateImpl = { it.invoke() },
cancelImpl = {}
)
}
}
val context = LocalContext.current
val view = LocalView.current
val host = remember(view, context) { resolveHost(context, view) }
if (host == null) {
error("FragmentActivity is required to use rememberBiometricsAuthentication()")
}
val resolvedTitle = promptTitle?.takeIf { it.isNotBlank() }
check(resolvedTitle != null) {
"promptTitle must be non-blank when using rememberBiometricsAuthentication()"
}
// Fallback to device credential confirmation when BiometricPrompt isn't available.
var pendingAction by remember { mutableStateOf<(() -> Unit)?>(null) }
val deviceCredentialLauncher = rememberLauncherForActivityResult(BiometricDeviceLockContract()) { result ->
if (result == BiometricDeviceAuthentication.AUTHENTICATED) {
pendingAction?.invoke()
pendingAction = null
}
}
val biometricManager = remember(context) { BiometricManager.from(context) }
val biometricPrompt = remember(host.activity, host.fragment, context) {
val executor = ContextCompat.getMainExecutor(context)
val callback = object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationFailed() {
onAuthenticationFailed?.invoke()
}
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
pendingAction?.invoke()
pendingAction = null
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
onAuthenticationFailed?.invoke()
}
}
host.fragment?.let { fragment ->
BiometricPrompt(fragment, executor, callback)
} ?: BiometricPrompt(host.activity, executor, callback)
}
val biometricDeviceAuthentication = remember(biometricManager, biometricPrompt) {
// Prompt info is updated below on each call to `withBiometricsAuthentication`.
val initialPromptInfo = BiometricPrompt.PromptInfo.Builder()
.setAllowedAuthenticators(BiometricDeviceAuthentication.ALLOWED_AUTHENTICATORS)
.setTitle(" ")
.build()
BiometricDeviceAuthentication(biometricManager, biometricPrompt, initialPromptInfo)
}
val shouldShowEducationSheetForFlow = biometricDeviceAuthentication.shouldShowEducationSheet(context)
fun authenticateOrFallback(promptTitleForPrompt: String) {
val action = pendingAction ?: return
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setAllowedAuthenticators(BiometricDeviceAuthentication.ALLOWED_AUTHENTICATORS)
.setTitle(promptTitleForPrompt)
.build()
biometricDeviceAuthentication.updatePromptInfo(promptInfo)
if (!biometricDeviceAuthentication.authenticate(context, true) {
deviceCredentialLauncher.launch(promptTitleForPrompt)
}
) {
// If we cannot authenticate at all, preserve existing call-site behavior and just proceed.
action.invoke()
pendingAction = null
}
}
// If the composable that owns this helper leaves composition (navigation, conditional UI, etc.),
// ensure we don't keep an auth prompt open or deliver a stale callback later.
DisposableEffect(biometricDeviceAuthentication) {
onDispose {
biometricDeviceAuthentication.cancelAuthentication()
pendingAction = null
}
}
return BiometricsAuthentication(
authenticateImpl = { onAuthenticated ->
pendingAction = onAuthenticated
if (shouldShowEducationSheetForFlow && !educationSheetMessage.isNullOrBlank()) {
DevicePinAuthEducationSheet.show(educationSheetMessage, host.fragmentManager)
host.fragmentManager.setFragmentResultListener(
DevicePinAuthEducationSheet.REQUEST_KEY,
host.resultLifecycleOwner
) { _, _ ->
authenticateOrFallback(resolvedTitle)
}
} else {
authenticateOrFallback(resolvedTitle)
}
},
cancelImpl = biometricDeviceAuthentication::cancelAuthentication
)
}
@Stable
private data class Host(
val activity: FragmentActivity,
val fragment: Fragment?,
val fragmentManager: FragmentManager,
val resultLifecycleOwner: LifecycleOwner
)
private fun resolveHost(context: Context, view: View): Host? {
val fragment = runCatching { view.findFragment<Fragment>() }.getOrNull()
if (fragment != null) {
return Host(
activity = fragment.requireActivity(),
fragment = fragment,
fragmentManager = fragment.parentFragmentManager,
resultLifecycleOwner = fragment.viewLifecycleOwner
)
}
val activity = context as? FragmentActivity ?: return null
return Host(
activity = activity,
fragment = null,
fragmentManager = activity.supportFragmentManager,
resultLifecycleOwner = activity
)
}

View File

@@ -23,6 +23,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.service.KeyCachingService
import org.thoughtcrime.securesms.util.CachedInflater
import org.thoughtcrime.securesms.util.DynamicTheme
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.SignalE164Util
import org.thoughtcrime.securesms.util.navigation.safeNavigate
@@ -48,10 +49,16 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
val startingAction: NavDirections? = if (intent?.categories?.contains(NOTIFICATION_CATEGORY) == true) {
AppSettingsFragmentDirections.actionDirectToNotificationsSettingsFragment()
} else {
val appSettingsRoute: AppSettingsRoute? = intent?.getParcelableExtraCompat(START_ROUTE, AppSettingsRoute::class.java)
when (appSettingsRoute) {
when (val appSettingsRoute: AppSettingsRoute? = intent?.getParcelableExtraCompat(START_ROUTE, AppSettingsRoute::class.java)) {
AppSettingsRoute.Empty -> null
AppSettingsRoute.BackupsRoute.Local -> AppSettingsFragmentDirections.actionDirectToBackupsPreferenceFragment()
is AppSettingsRoute.BackupsRoute.Local -> {
if (SignalStore.backup.newLocalBackupsEnabled || RemoteConfig.unifiedLocalBackups && (!SignalStore.settings.isBackupEnabled || appSettingsRoute.triggerUpdateFlow)) {
AppSettingsFragmentDirections.actionDirectToLocalBackupsFragment()
.setTriggerUpdateFlow(appSettingsRoute.triggerUpdateFlow)
} else {
AppSettingsFragmentDirections.actionDirectToBackupsPreferenceFragment()
}
}
is AppSettingsRoute.HelpRoute.Settings -> AppSettingsFragmentDirections.actionDirectToHelpFragment()
.setStartCategoryIndex(appSettingsRoute.startCategoryIndex)
AppSettingsRoute.DataAndStorageRoute.Proxy -> AppSettingsFragmentDirections.actionDirectToEditProxyFragment()
@@ -152,7 +159,7 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
}
@JvmStatic
fun backups(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.BackupsRoute.Local)
fun backups(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.BackupsRoute.Local())
@JvmStatic
fun help(context: Context, startCategoryIndex: Int = 0): Intent {
@@ -227,6 +234,9 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
@JvmStatic
fun invite(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.Invite)
@JvmStatic
fun upgradeLocalBackups(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.BackupsRoute.Local(triggerUpdateFlow = true))
private fun getIntentForStartLocation(context: Context, startRoute: AppSettingsRoute): Intent {
return Intent(context, AppSettingsActivity::class.java)
.putExtra(ARG_NAV_GRAPH, R.navigation.app_settings_with_change_number)

View File

@@ -29,9 +29,11 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -56,6 +58,7 @@ import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import java.math.BigDecimal
import java.util.Currency
@@ -101,7 +104,13 @@ class BackupsSettingsFragment : ComposeFragment() {
}
}
},
onOnDeviceBackupsRowClick = { findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_backupsPreferenceFragment) },
onOnDeviceBackupsRowClick = {
if (SignalStore.backup.newLocalBackupsEnabled || RemoteConfig.unifiedLocalBackups && !SignalStore.settings.isBackupEnabled) {
findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_localBackupsFragment)
} else {
findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_backupsPreferenceFragment)
}
},
onNewOnDeviceBackupsRowClick = { findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_internalLocalBackupFragment) },
onBackupTierInternalOverrideChanged = { viewModel.onBackupTierInternalOverrideChanged(it) }
)
@@ -228,6 +237,7 @@ private fun BackupsSettingsContent(
item {
Rows.TextRow(
text = stringResource(R.string.RemoteBackupsSettingsFragment__on_device_backups),
icon = ImageVector.vectorResource(R.drawable.symbol_device_phone_24),
label = stringResource(R.string.RemoteBackupsSettingsFragment__save_your_backups_to),
onClick = onOnDeviceBackupsRowClick
)

View File

@@ -0,0 +1,26 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.backups.local
/**
* Progress indicator state for the on-device backups creation/verification workflow.
*/
sealed class BackupProgressState {
data object Idle : BackupProgressState()
/**
* Represents either backup creation or verification progress.
*
* @param summary High-level status label (e.g. "In progress…", "Verifying backup…")
* @param percentLabel Secondary progress label (either a percent string or a count-based string)
* @param progressFraction Optional progress fraction in \\([0, 1]\\). Null indicates indeterminate progress.
*/
data class InProgress(
val summary: String,
val percentLabel: String,
val progressFraction: Float?
) : BackupProgressState()
}

View File

@@ -0,0 +1,177 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.backups.local
import android.app.Activity
import android.content.Intent
import android.widget.Toast
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.fragment.navArgs
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner
import kotlinx.coroutines.launch
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsKeyEducationScreen
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsKeyEducationScreenMode
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsKeyRecordMode
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsKeyRecordScreen
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsKeyVerifyScreen
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.keyvalue.SignalStore
private val TAG = Log.tag(LocalBackupsFragment::class)
/**
* On-device backups settings screen, replaces `BackupsPreferenceFragment` and contains the key upgrade flow.
*/
class LocalBackupsFragment : ComposeFragment() {
private val args: LocalBackupsFragmentArgs by navArgs()
@Composable
override fun FragmentContent() {
val initialStack = if (args.triggerUpdateFlow) {
arrayOf(LocalBackupsNavKey.IMPROVEMENTS)
} else {
arrayOf(LocalBackupsNavKey.SETTINGS)
}
val backstack = rememberNavBackStack(*initialStack)
val snackbarHostState = remember { SnackbarHostState() }
val viewModel = viewModel<LocalBackupsViewModel>()
CompositionLocalProvider(LocalNavigationEventDispatcherOwner provides requireActivity()) {
NavDisplay(
backStack = backstack,
entryProvider = { key ->
when (key) {
LocalBackupsNavKey.SETTINGS -> NavEntry(key) {
val chooseBackupLocationLauncher = rememberChooseBackupLocationLauncher(backstack)
val state by viewModel.settingsState.collectAsStateWithLifecycle()
val callback: LocalBackupsSettingsCallback = remember(
chooseBackupLocationLauncher
) {
DefaultLocalBackupsSettingsCallback(
fragment = this,
chooseBackupLocationLauncher = chooseBackupLocationLauncher,
viewModel = viewModel
)
}
LifecycleResumeEffect(Unit) {
viewModel.refreshSettingsState()
onPauseOrDispose {}
}
LocalBackupsSettingsScreen(
state = state,
callback = callback,
snackbarHostState = snackbarHostState
)
}
LocalBackupsNavKey.IMPROVEMENTS -> NavEntry(key) {
val backPressedDispatcher = LocalOnBackPressedDispatcherOwner.current
LocalBackupsImprovementsScreen(
onNavigationClick = { backPressedDispatcher?.onBackPressedDispatcher?.onBackPressed() },
onContinueClick = { backstack.add(LocalBackupsNavKey.YOUR_RECOVERY_KEY) }
)
}
LocalBackupsNavKey.YOUR_RECOVERY_KEY -> NavEntry(key) {
val backPressedDispatcher = LocalOnBackPressedDispatcherOwner.current
MessageBackupsKeyEducationScreen(
onNavigationClick = { backPressedDispatcher?.onBackPressedDispatcher?.onBackPressed() },
onNextClick = { backstack.add(LocalBackupsNavKey.RECORD_RECOVERY_KEY) },
mode = MessageBackupsKeyEducationScreenMode.LOCAL_BACKUP_UPGRADE
)
}
LocalBackupsNavKey.RECORD_RECOVERY_KEY -> NavEntry(key) {
val state: LocalBackupsKeyState by viewModel.backupState.collectAsStateWithLifecycle()
MessageBackupsKeyRecordScreen(
backupKey = state.accountEntropyPool.displayValue,
keySaveState = state.keySaveState,
backupKeyCredentialManagerHandler = viewModel,
mode = MessageBackupsKeyRecordMode.Next {
backstack.add(LocalBackupsNavKey.CONFIRM_RECOVERY_KEY)
}
)
}
LocalBackupsNavKey.CONFIRM_RECOVERY_KEY -> NavEntry(key) {
val state: LocalBackupsKeyState by viewModel.backupState.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
val backupKeyUpdatedMessage = stringResource(R.string.OnDeviceBackupsFragment__backup_key_updated)
MessageBackupsKeyVerifyScreen(
backupKey = state.accountEntropyPool.displayValue,
onNavigationClick = { requireActivity().onBackPressedDispatcher.onBackPressed() },
onNextClick = {
if (!backstack.contains(LocalBackupsNavKey.SETTINGS)) {
backstack.add(0, LocalBackupsNavKey.SETTINGS)
}
backstack.removeAll { it != LocalBackupsNavKey.SETTINGS }
scope.launch {
viewModel.handleUpgrade(requireContext())
snackbarHostState.showSnackbar(
message = backupKeyUpdatedMessage
)
}
}
)
}
else -> error("Unknown key: $key")
}
}
)
}
}
}
@Composable
private fun rememberChooseBackupLocationLauncher(backStack: NavBackStack<NavKey>): ActivityResultLauncher<Intent> {
val context = LocalContext.current
return rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val uri = result.data?.data
if (result.resultCode == Activity.RESULT_OK && uri != null) {
Log.i(TAG, "Backup location selected: $uri")
val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
context.contentResolver.takePersistableUriPermission(uri, takeFlags)
SignalStore.backup.newLocalBackupsDirectory = uri.toString()
backStack.add(LocalBackupsNavKey.YOUR_RECOVERY_KEY)
Toast.makeText(context, context.getString(R.string.OnDeviceBackupsFragment__directory_selected, uri), Toast.LENGTH_SHORT).show()
} else {
Log.w(TAG, "Unified backup location selection cancelled or failed")
}
}
}

View File

@@ -0,0 +1,161 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.backups.local
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.horizontalGutters
import org.thoughtcrime.securesms.R
import org.signal.core.ui.R as CoreUiR
/**
* Screen explaining the improvements made to the on-device backups experience.
*/
@Composable
fun LocalBackupsImprovementsScreen(
onNavigationClick: () -> Unit = {},
onContinueClick: () -> Unit = {}
) {
Scaffolds.Settings(
title = "",
navigationIcon = ImageVector.vectorResource(CoreUiR.drawable.symbol_x_24),
onNavigationClick = onNavigationClick
) {
Column(
Modifier
.padding(it)
.horizontalGutters()
.fillMaxSize()
) {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally
) {
item {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.symbol_folder_24),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier
.padding(top = 24.dp, bottom = 16.dp)
.size(80.dp)
.background(color = MaterialTheme.colorScheme.secondaryContainer, shape = CircleShape)
.padding(16.dp)
)
}
item {
Text(
text = stringResource(R.string.OnDeviceBackupsImprovementsScreen__improvements_to_on_device_backups),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center
)
}
item {
Text(
text = stringResource(R.string.OnDeviceBackupsImprovementsScreen__your_on_device_backup_will_be_upgraded),
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 12.dp, bottom = 36.dp)
)
}
item {
FeatureRow(
imageVector = ImageVector.vectorResource(CoreUiR.drawable.symbol_backup_24),
text = stringResource(R.string.OnDeviceBackupsImprovementsScreen__backups_now_save_faster)
)
}
item {
FeatureRow(
imageVector = ImageVector.vectorResource(R.drawable.symbol_folder_24),
text = stringResource(R.string.OnDeviceBackupsImprovementsScreen__your_backup_will_be_saved_as_a_folder)
)
}
item {
FeatureRow(
imageVector = ImageVector.vectorResource(R.drawable.symbol_key_24),
text = stringResource(R.string.OnDeviceBackupsImprovementsScreen__all_backups_remain_end_to_end_encrypted)
)
}
}
Box(
contentAlignment = Alignment.CenterEnd,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 24.dp)
) {
Buttons.LargeTonal(
onClick = onContinueClick
) {
Text(text = stringResource(R.string.OnDeviceBackupsImprovementsScreen__continue))
}
}
}
}
}
@Composable
private fun FeatureRow(
imageVector: ImageVector,
text: String
) {
Row(
modifier = Modifier.padding(bottom = 16.dp)
) {
Icon(
imageVector = imageVector,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = text,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.padding(start = 16.dp)
.widthIn(max = 217.dp)
)
}
}
@DayNightPreviews
@Composable
private fun LocalBackupsImprovementsScreenPreview() {
Previews.Preview {
LocalBackupsImprovementsScreen()
}
}

View File

@@ -0,0 +1,15 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.backups.local
import org.signal.core.models.AccountEntropyPool
import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeySaveState
import org.thoughtcrime.securesms.keyvalue.SignalStore
data class LocalBackupsKeyState(
val accountEntropyPool: AccountEntropyPool = SignalStore.account.accountEntropyPool,
val keySaveState: BackupKeySaveState? = null
)

View File

@@ -0,0 +1,16 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.backups.local
import androidx.navigation3.runtime.NavKey
enum class LocalBackupsNavKey : NavKey {
SETTINGS,
IMPROVEMENTS,
YOUR_RECOVERY_KEY,
RECORD_RECOVERY_KEY,
CONFIRM_RECOVERY_KEY
}

View File

@@ -0,0 +1,166 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.backups.local
import android.Manifest
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Build
import android.provider.DocumentsContract
import android.text.format.DateFormat
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.navigation.fragment.findNavController
import com.google.android.material.timepicker.MaterialTimePicker
import com.google.android.material.timepicker.TimeFormat
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.LocalBackupJob
import org.thoughtcrime.securesms.jobs.LocalBackupJob.enqueueArchive
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.service.LocalBackupListener
import org.thoughtcrime.securesms.util.BackupUtil
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.navigation.safeNavigate
sealed interface LocalBackupsSettingsCallback {
fun onNavigationClick()
fun onTurnOnClick()
fun onCreateBackupClick()
fun onPickTimeClick()
fun onViewBackupKeyClick()
fun onLearnMoreClick()
fun onLaunchBackupLocationPickerClick()
fun onTurnOffAndDeleteConfirmed()
object Empty : LocalBackupsSettingsCallback {
override fun onNavigationClick() = Unit
override fun onTurnOnClick() = Unit
override fun onCreateBackupClick() = Unit
override fun onPickTimeClick() = Unit
override fun onViewBackupKeyClick() = Unit
override fun onLearnMoreClick() = Unit
override fun onLaunchBackupLocationPickerClick() = Unit
override fun onTurnOffAndDeleteConfirmed() = Unit
}
}
class DefaultLocalBackupsSettingsCallback(
private val fragment: LocalBackupsFragment,
private val chooseBackupLocationLauncher: ActivityResultLauncher<Intent>,
private val viewModel: LocalBackupsViewModel
) : LocalBackupsSettingsCallback {
companion object {
private val TAG = Log.tag(LocalBackupsSettingsCallback::class)
}
override fun onNavigationClick() {
fragment.requireActivity().onBackPressedDispatcher.onBackPressed()
}
override fun onLaunchBackupLocationPickerClick() {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
if (Build.VERSION.SDK_INT >= 26) {
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, SignalStore.settings.latestSignalBackupDirectory)
}
intent.addFlags(
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
try {
Log.d(TAG, "Starting choose backup location dialog")
chooseBackupLocationLauncher.launch(intent)
} catch (e: ActivityNotFoundException) {
Toast.makeText(fragment.requireContext(), R.string.BackupDialog_no_file_picker_available, Toast.LENGTH_LONG).show()
}
}
override fun onPickTimeClick() {
val timeFormat = if (DateFormat.is24HourFormat(fragment.requireContext())) {
TimeFormat.CLOCK_24H
} else {
TimeFormat.CLOCK_12H
}
val picker = MaterialTimePicker.Builder()
.setTimeFormat(timeFormat)
.setHour(SignalStore.settings.backupHour)
.setMinute(SignalStore.settings.backupMinute)
.setTitleText(R.string.BackupsPreferenceFragment__set_backup_time)
.build()
picker.addOnPositiveButtonClickListener {
SignalStore.settings.setBackupSchedule(picker.hour, picker.minute)
TextSecurePreferences.setNextBackupTime(fragment.requireContext(), 0)
LocalBackupListener.schedule(fragment.requireContext())
viewModel.refreshSettingsState()
}
picker.show(fragment.childFragmentManager, "TIME_PICKER")
}
override fun onCreateBackupClick() {
if (BackupUtil.isUserSelectionRequired(fragment.requireContext())) {
Log.i(TAG, "Queueing backup...")
enqueueArchive(false)
} else {
Permissions.with(fragment)
.request(Manifest.permission.WRITE_EXTERNAL_STORAGE)
.ifNecessary()
.onAllGranted {
Log.i(TAG, "Queuing backup...")
enqueueArchive(false)
}
.withPermanentDenialDialog(
fragment.getString(R.string.BackupsPreferenceFragment_signal_requires_external_storage_permission_in_order_to_create_backups)
)
.execute()
}
}
override fun onTurnOnClick() {
if (BackupUtil.isUserSelectionRequired(fragment.requireContext())) {
// When the user-selection flow is required, the screen shows a compose dialog and then
// triggers [launchBackupDirectoryPicker] via callback.
// This method intentionally does nothing in that case.
} else {
Permissions.with(fragment)
.request(Manifest.permission.WRITE_EXTERNAL_STORAGE)
.ifNecessary()
.onAllGranted {
onLaunchBackupLocationPickerClick()
}
.withPermanentDenialDialog(
fragment.getString(R.string.BackupsPreferenceFragment_signal_requires_external_storage_permission_in_order_to_create_backups)
)
.execute()
}
}
override fun onViewBackupKeyClick() {
fragment.findNavController().safeNavigate(R.id.action_backupsPreferenceFragment_to_backupKeyDisplayFragment)
}
override fun onLearnMoreClick() {
CommunicationActions.openBrowserLink(fragment.requireContext(), fragment.getString(R.string.backup_support_url))
}
override fun onTurnOffAndDeleteConfirmed() {
SignalStore.backup.newLocalBackupsEnabled = false
val path = SignalStore.backup.newLocalBackupsDirectory
SignalStore.backup.newLocalBackupsDirectory = null
AppDependencies.jobManager.cancelAllInQueue(LocalBackupJob.QUEUE)
BackupUtil.deleteUnifiedBackups(fragment.requireContext(), path)
}
}

View File

@@ -0,0 +1,361 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.backups.local
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withLink
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Dividers
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Rows
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.Snackbars
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.compose.rememberBiometricsAuthentication
import org.thoughtcrime.securesms.util.BackupUtil
import org.signal.core.ui.R as CoreUiR
import org.signal.core.ui.compose.DayNightPreviews as DayNightPreview
@Composable
internal fun LocalBackupsSettingsScreen(
state: LocalBackupsSettingsState,
callback: LocalBackupsSettingsCallback,
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }
) {
val context = LocalContext.current
var showChooseLocationDialog by rememberSaveable { mutableStateOf(false) }
var showTurnOffAndDeleteDialog by rememberSaveable { mutableStateOf(false) }
val learnMore = stringResource(id = R.string.BackupsPreferenceFragment__learn_more)
val restoreText = stringResource(id = R.string.OnDeviceBackupsScreen__to_restore_a_backup, learnMore).trim()
val learnMoreColor = MaterialTheme.colorScheme.primary
val restoreInfo = remember(restoreText, learnMore, learnMoreColor) {
buildAnnotatedString {
append(restoreText)
append(" ")
withLink(
LinkAnnotation.Clickable(
tag = "learn-more",
linkInteractionListener = { callback.onLearnMoreClick() },
styles = TextLinkStyles(style = SpanStyle(color = learnMoreColor))
)
) {
append(learnMore)
}
}
}
val biometrics = rememberBiometricsAuthentication(
promptTitle = stringResource(R.string.RemoteBackupsSettingsFragment__unlock_to_view_backup_key),
educationSheetMessage = stringResource(R.string.RemoteBackupsSettingsFragment__to_view_your_key)
)
Scaffolds.Settings(
title = stringResource(id = R.string.RemoteBackupsSettingsFragment__on_device_backups),
navigationIcon = ImageVector.vectorResource(CoreUiR.drawable.symbol_arrow_start_24),
onNavigationClick = callback::onNavigationClick,
snackbarHost = {
Snackbars.Host(snackbarHostState)
}
) { paddingValues ->
LazyColumn(
modifier = Modifier.padding(paddingValues)
) {
if (!state.backupsEnabled) {
item {
Rows.TextRow(
text = {
Column {
Text(
text = stringResource(id = R.string.BackupsPreferenceFragment__backups_are_encrypted_with_a_passphrase),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium
)
Buttons.MediumTonal(
onClick = {
// For the SAF-based flow, present an in-screen dialog before launching the picker.
if (BackupUtil.isUserSelectionRequired(context)) {
showChooseLocationDialog = true
} else {
callback.onTurnOnClick()
}
},
enabled = state.canTurnOn,
modifier = Modifier.padding(top = 12.dp)
) {
Text(text = stringResource(id = R.string.BackupsPreferenceFragment__turn_on))
}
}
}
)
}
} else {
val isCreating = state.progress is BackupProgressState.InProgress
item {
Rows.TextRow(
text = {
Column {
Text(
text = stringResource(id = R.string.BackupsPreferenceFragment__create_backup),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
if (state.progress is BackupProgressState.InProgress) {
Text(
text = state.progress.summary,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp)
)
if (state.progress.progressFraction == null) {
LinearProgressIndicator(
trackColor = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
)
} else {
LinearProgressIndicator(
trackColor = MaterialTheme.colorScheme.secondaryContainer,
progress = { state.progress.progressFraction },
drawStopIndicator = {},
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
)
}
Text(
text = state.progress.percentLabel,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp)
)
} else {
Text(
text = state.lastBackupLabel.orEmpty(),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp)
)
}
}
},
enabled = !isCreating,
onClick = callback::onCreateBackupClick
)
}
item {
Rows.TextRow(
text = stringResource(id = R.string.BackupsPreferenceFragment__backup_time),
label = state.scheduleTimeLabel,
onClick = callback::onPickTimeClick
)
}
if (!state.folderDisplayName.isNullOrBlank()) {
item {
Rows.TextRow(
text = stringResource(id = R.string.BackupsPreferenceFragment__backup_folder),
label = state.folderDisplayName
)
}
}
item {
Rows.TextRow(
text = stringResource(id = R.string.UnifiedOnDeviceBackupsSettingsScreen__view_backup_key),
onClick = { biometrics.withBiometricsAuthentication { callback.onViewBackupKeyClick() } }
)
}
}
if (state.backupsEnabled) {
item {
Rows.TextRow(
text = stringResource(id = R.string.RemoteBackupsSettingsFragment__turn_off_and_delete),
foregroundTint = MaterialTheme.colorScheme.error,
onClick = { showTurnOffAndDeleteDialog = true }
)
}
}
item {
Dividers.Default()
}
item {
Text(
text = restoreInfo,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(
horizontal = dimensionResource(id = org.signal.core.ui.R.dimen.gutter),
vertical = 16.dp
)
)
}
}
}
if (showChooseLocationDialog) {
Dialogs.SimpleAlertDialog(
title = stringResource(id = R.string.BackupDialog_enable_local_backups),
body = stringResource(id = R.string.BackupDialog_to_enable_backups_choose_a_folder),
confirm = stringResource(id = R.string.BackupDialog_choose_folder),
dismiss = stringResource(id = android.R.string.cancel),
onConfirm = callback::onLaunchBackupLocationPickerClick,
onDismiss = { showChooseLocationDialog = false }
)
}
if (showTurnOffAndDeleteDialog) {
Dialogs.SimpleAlertDialog(
title = stringResource(id = R.string.BackupDialog_delete_backups),
body = stringResource(id = R.string.BackupDialog_disable_and_delete_all_local_backups),
confirm = stringResource(id = R.string.BackupDialog_delete_backups_statement),
confirmColor = MaterialTheme.colorScheme.error,
dismiss = stringResource(id = android.R.string.cancel),
onConfirm = callback::onTurnOffAndDeleteConfirmed,
onDismiss = { showTurnOffAndDeleteDialog = false }
)
}
}
@DayNightPreview
@Composable
private fun OnDeviceBackupsDisabledCanTurnOnPreviewSettings() {
Previews.Preview {
LocalBackupsSettingsScreen(
state = LocalBackupsSettingsState(
backupsEnabled = false,
canTurnOn = true
),
callback = LocalBackupsSettingsCallback.Empty
)
}
}
@DayNightPreview
@Composable
private fun OnDeviceBackupsDisabledCannotTurnOnPreviewSettings() {
Previews.Preview {
LocalBackupsSettingsScreen(
state = LocalBackupsSettingsState(
backupsEnabled = false,
canTurnOn = false
),
callback = LocalBackupsSettingsCallback.Empty
)
}
}
@DayNightPreview
@Composable
private fun LocalBackupsSettingsEnabledIdlePreview() {
Previews.Preview {
LocalBackupsSettingsScreen(
state = LocalBackupsSettingsState(
backupsEnabled = true,
lastBackupLabel = "Last backup: 1 hour ago",
folderDisplayName = "/storage/emulated/0/Signal/Backups",
scheduleTimeLabel = "1:00 AM",
progress = BackupProgressState.Idle
),
callback = LocalBackupsSettingsCallback.Empty
)
}
}
@DayNightPreview
@Composable
private fun LocalBackupsSettingsEnabledInProgressIndeterminatePreview() {
Previews.Preview {
LocalBackupsSettingsScreen(
state = LocalBackupsSettingsState(
backupsEnabled = true,
lastBackupLabel = "Last backup: 1 hour ago",
folderDisplayName = "/storage/emulated/0/Signal/Backups",
scheduleTimeLabel = "1:00 AM",
progress = BackupProgressState.InProgress(
summary = "In progress…",
percentLabel = "123 so far…",
progressFraction = null
)
),
callback = LocalBackupsSettingsCallback.Empty
)
}
}
@DayNightPreview
@Composable
private fun LocalBackupsSettingsEnabledInProgressPercentPreview() {
Previews.Preview {
LocalBackupsSettingsScreen(
state = LocalBackupsSettingsState(
backupsEnabled = true,
lastBackupLabel = "Last backup: 1 hour ago",
folderDisplayName = "/storage/emulated/0/Signal/Backups",
scheduleTimeLabel = "1:00 AM",
progress = BackupProgressState.InProgress(
summary = "In progress…",
percentLabel = "42.0% so far…",
progressFraction = 0.42f
)
),
callback = LocalBackupsSettingsCallback.Empty
)
}
}
@DayNightPreview
@Composable
private fun LocalBackupsSettingsEnabledNonLegacyPreview() {
Previews.Preview {
LocalBackupsSettingsScreen(
state = LocalBackupsSettingsState(
backupsEnabled = true,
lastBackupLabel = "Last backup: 1 hour ago",
folderDisplayName = "Signal Backups",
scheduleTimeLabel = "1:00 AM",
progress = BackupProgressState.Idle
),
callback = LocalBackupsSettingsCallback.Empty
)
}
}

View File

@@ -0,0 +1,20 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.backups.local
/**
* Immutable state for the on-device (legacy) backups settings screen.
*
* This is intended to be the single source of truth for UI rendering (i.e. a single `StateFlow`
* emission fully describes what the screen should display).
*/
data class LocalBackupsSettingsState(
val backupsEnabled: Boolean = false,
val canTurnOn: Boolean = true,
val lastBackupLabel: String? = null,
val folderDisplayName: String? = null,
val scheduleTimeLabel: String? = null,
val progress: BackupProgressState = BackupProgressState.Idle
)

View File

@@ -0,0 +1,191 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.backups.local
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.BackupPassphrase
import org.thoughtcrime.securesms.backup.v2.LocalBackupV2Event
import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeyCredentialManagerHandler
import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeySaveState
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.LocalBackupJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.BackupUtil
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.formatHours
import java.text.NumberFormat
import java.time.LocalTime
import java.util.Locale
/**
* Unified data model backups. Shares the same schema and file breakout as remote backups/.
*/
class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
companion object {
private val TAG = Log.tag(LocalBackupsViewModel::class)
}
private val formatter: NumberFormat = NumberFormat.getInstance().apply {
minimumFractionDigits = 1
maximumFractionDigits = 1
}
private val internalSettingsState = MutableStateFlow(
LocalBackupsSettingsState(
backupsEnabled = SignalStore.backup.newLocalBackupsEnabled,
folderDisplayName = SignalStore.backup.newLocalBackupsDirectory
)
)
private val internalBackupState = MutableStateFlow(LocalBackupsKeyState())
val settingsState = internalSettingsState
val backupState = internalBackupState
init {
val applicationContext = AppDependencies.application
viewModelScope.launch {
SignalStore.backup.newLocalBackupsEnabledFlow.collect { enabled ->
internalSettingsState.update { it.copy(backupsEnabled = enabled) }
}
}
viewModelScope.launch {
SignalStore.backup.newLocalBackupsDirectoryFlow.collect { directory ->
internalSettingsState.update { it.copy(folderDisplayName = directory) }
}
}
viewModelScope.launch {
SignalStore.backup.newLocalBackupsLastBackupTimeFlow.collect { lastBackupTime ->
internalSettingsState.update { it.copy(lastBackupLabel = calculateLastBackupTimeString(applicationContext, lastBackupTime)) }
}
}
EventBus.getDefault().register(this)
}
override fun onCleared() {
EventBus.getDefault().unregister(this)
}
fun refreshSettingsState() {
val context = AppDependencies.application
val backupTime = LocalTime.of(SignalStore.settings.backupHour, SignalStore.settings.backupMinute).formatHours(context)
val userUnregistered = TextSecurePreferences.isUnauthorizedReceived(context) || !SignalStore.account.isRegistered
val clientDeprecated = SignalStore.misc.isClientDeprecated
val legacyLocalBackupsEnabled = SignalStore.settings.isBackupEnabled && BackupUtil.canUserAccessBackupDirectory(context)
val canTurnOn = legacyLocalBackupsEnabled || (!userUnregistered && !clientDeprecated)
val isLegacyBackup = !RemoteConfig.unifiedLocalBackups || (SignalStore.settings.isBackupEnabled && !SignalStore.backup.newLocalBackupsEnabled)
if (SignalStore.backup.newLocalBackupsEnabled) {
if (!BackupUtil.canUserAccessUnifiedBackupDirectory(context)) {
Log.w(TAG, "Lost access to backup directory, disabling backups")
SignalStore.backup.newLocalBackupsEnabled = false
AppDependencies.jobManager.cancelAllInQueue(LocalBackupJob.QUEUE)
}
} else {
AppDependencies.jobManager.cancelAllInQueue(LocalBackupJob.QUEUE)
}
internalSettingsState.update {
it.copy(
canTurnOn = canTurnOn,
scheduleTimeLabel = backupTime
)
}
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun onBackupEvent(event: LocalBackupV2Event) {
val context = AppDependencies.application
when (event.type) {
LocalBackupV2Event.Type.FINISHED -> {
internalSettingsState.update { it.copy(progress = BackupProgressState.Idle) }
}
else -> {
val summary = context.getString(R.string.BackupsPreferenceFragment__in_progress)
val progressState = if (event.estimatedTotalCount == 0L) {
BackupProgressState.InProgress(
summary = summary,
percentLabel = context.getString(R.string.BackupsPreferenceFragment__d_so_far, event.count),
progressFraction = null
)
} else {
val fraction = ((event.count / event.estimatedTotalCount.toDouble()) / 100.0).toFloat().coerceIn(0f, 1f)
BackupProgressState.InProgress(
summary = summary,
percentLabel = context.getString(R.string.BackupsPreferenceFragment__s_so_far, formatter.format((event.count / event.estimatedTotalCount.toDouble()))),
progressFraction = fraction
)
}
internalSettingsState.update { it.copy(progress = progressState) }
}
}
}
override fun updateBackupKeySaveState(newState: BackupKeySaveState?) {
internalBackupState.update { it.copy(keySaveState = newState) }
}
suspend fun handleUpgrade(context: Context) {
if (SignalStore.settings.isBackupEnabled) {
withContext(Dispatchers.IO) {
AppDependencies.jobManager.cancelAllInQueue(LocalBackupJob.QUEUE)
AppDependencies.jobManager.flush()
}
SignalStore.backup.newLocalBackupsDirectory = SignalStore.settings.signalBackupDirectory?.toString()
BackupPassphrase.set(context, null)
SignalStore.settings.isBackupEnabled = false
BackupUtil.deleteAllBackups()
}
SignalStore.backup.newLocalBackupsEnabled = true
LocalBackupJob.enqueueArchive(false)
}
}
private fun calculateLastBackupTimeString(context: Context, lastBackupTimestamp: Long): String {
return if (lastBackupTimestamp > 0) {
val relativeTime = DateUtils.getDatelessRelativeTimeSpanFormattedDate(
context,
Locale.getDefault(),
lastBackupTimestamp
)
if (relativeTime.isRelative) {
relativeTime.value
} else {
val day = DateUtils.getDayPrecisionTimeString(context, Locale.getDefault(), lastBackupTimestamp)
val time = relativeTime.value
context.getString(R.string.RemoteBackupsSettingsFragment__s_at_s, day, time)
}
} else {
context.getString(R.string.RemoteBackupsSettingsFragment__never)
}
}

View File

@@ -28,8 +28,6 @@ import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsKeyRec
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsKeyVerifyScreen
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.compose.Nav
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.storage.AndroidCredentialRepository
import org.thoughtcrime.securesms.util.viewModel
/**
@@ -39,7 +37,6 @@ class BackupKeyDisplayFragment : ComposeFragment() {
companion object {
const val AEP_ROTATION_KEY = "AEP_ROTATION_KEY"
const val CLIPBOARD_TIMEOUT_SECONDS = 60
}
private val viewModel: BackupKeyDisplayViewModel by viewModel { BackupKeyDisplayViewModel() }
@@ -48,7 +45,6 @@ class BackupKeyDisplayFragment : ComposeFragment() {
@Composable
override fun FragmentContent() {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val passwordManagerSettingsIntent = AndroidCredentialRepository.getCredentialManagerSettingsIntent(requireContext())
val navController = rememberNavController()
LaunchedEffect(Unit) {
@@ -121,14 +117,8 @@ class BackupKeyDisplayFragment : ComposeFragment() {
MessageBackupsKeyRecordScreen(
backupKey = state.accountEntropyPool.displayValue,
keySaveState = state.keySaveState,
canOpenPasswordManagerSettings = passwordManagerSettingsIntent != null,
onNavigationClick = { onBackPressedDispatcher?.onBackPressed() },
onCopyToClipboardClick = { Util.copyToClipboard(requireContext(), it, CLIPBOARD_TIMEOUT_SECONDS) },
onRequestSaveToPasswordManager = viewModel::onBackupKeySaveRequested,
onConfirmSaveToPasswordManager = viewModel::onBackupKeySaveConfirmed,
onSaveToPasswordManagerComplete = viewModel::onBackupKeySaveCompleted,
mode = mode,
onGoToPasswordManagerSettingsClick = { requireContext().startActivity(passwordManagerSettingsIntent) }
backupKeyCredentialManagerHandler = viewModel,
mode = mode
)
}

View File

@@ -9,8 +9,6 @@ import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
@@ -86,12 +84,8 @@ import org.signal.core.ui.compose.horizontalGutters
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.bytes
import org.signal.core.util.gibiBytes
import org.signal.core.util.logging.Log
import org.signal.core.util.mebiBytes
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.BiometricDeviceAuthentication
import org.thoughtcrime.securesms.BiometricDeviceLockContract
import org.thoughtcrime.securesms.DevicePinAuthEducationSheet
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.ArchiveUploadProgress
import org.thoughtcrime.securesms.backup.DeletionState
@@ -107,6 +101,8 @@ import org.thoughtcrime.securesms.backup.v2.ui.status.RestoreType
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
import org.thoughtcrime.securesms.billing.launchManageBackupsSubscription
import org.thoughtcrime.securesms.components.compose.BetaHeader
import org.thoughtcrime.securesms.components.compose.BiometricsAuthentication
import org.thoughtcrime.securesms.components.compose.rememberBiometricsAuthentication
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.backups.BackupState
import org.thoughtcrime.securesms.components.settings.app.subscription.MessageBackupsCheckoutLauncher.createBackupsCheckoutLauncher
@@ -135,10 +131,6 @@ import org.signal.core.ui.R as CoreUiR
*/
class RemoteBackupsSettingsFragment : ComposeFragment() {
companion object {
private val TAG = Log.tag(RemoteBackupsSettingsFragment::class)
}
private val viewModel by viewModel {
RemoteBackupsSettingsViewModel()
}
@@ -146,8 +138,6 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
private val args: RemoteBackupsSettingsFragmentArgs by navArgs()
private lateinit var checkoutLauncher: ActivityResultLauncher<MessageBackupTier?>
private lateinit var biometricDeviceAuthentication: BiometricDeviceAuthentication
private lateinit var biometricFallbackLauncher: ActivityResultLauncher<String>
@Composable
override fun FragmentContent() {
@@ -213,16 +203,7 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
}
override fun onViewBackupKeyClick() {
if (biometricDeviceAuthentication.shouldShowEducationSheet(requireContext())) {
DevicePinAuthEducationSheet.show(getString(R.string.RemoteBackupsSettingsFragment__to_view_your_key), parentFragmentManager)
parentFragmentManager.setFragmentResultListener(DevicePinAuthEducationSheet.REQUEST_KEY, viewLifecycleOwner) { _, _ ->
if (!biometricDeviceAuthentication.authenticate(requireContext(), true, this@RemoteBackupsSettingsFragment::showConfirmDeviceCredentialIntent)) {
displayBackupKey()
}
}
} else if (!biometricDeviceAuthentication.authenticate(requireContext(), true, this@RemoteBackupsSettingsFragment::showConfirmDeviceCredentialIntent)) {
displayBackupKey()
}
displayBackupKey()
}
override fun onStartMediaRestore() {
@@ -308,10 +289,6 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
findNavController().safeNavigate(R.id.action_remoteBackupsSettingsFragment_to_backupKeyDisplayFragment)
}
private fun showConfirmDeviceCredentialIntent() {
biometricFallbackLauncher.launch(getString(R.string.RemoteBackupsSettingsFragment__unlock_to_view_backup_key))
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
checkoutLauncher = createBackupsCheckoutLauncher { backUpLater ->
@@ -320,15 +297,6 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
}
}
biometricFallbackLauncher = registerForActivityResult(
contract = BiometricDeviceLockContract(),
callback = { result ->
if (result == BiometricDeviceAuthentication.AUTHENTICATED) {
displayBackupKey()
}
}
)
setFragmentResultListener(BackupKeyDisplayFragment.AEP_ROTATION_KEY) { _, bundle ->
val didRotate = bundle.getBoolean(BackupKeyDisplayFragment.AEP_ROTATION_KEY, false)
if (didRotate) {
@@ -340,38 +308,12 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
if (savedInstanceState == null && args.backupLaterSelected) {
viewModel.requestSnackbar(RemoteBackupsSettingsState.Snackbar.BACKUP_WILL_BE_CREATED_OVERNIGHT)
}
val biometricManager = BiometricManager.from(requireContext())
val biometricPrompt = BiometricPrompt(this, AuthListener())
val promptInfo: BiometricPrompt.PromptInfo = BiometricPrompt.PromptInfo.Builder()
.setAllowedAuthenticators(BiometricDeviceAuthentication.ALLOWED_AUTHENTICATORS)
.setTitle(getString(R.string.RemoteBackupsSettingsFragment__unlock_to_view_backup_key))
.build()
biometricDeviceAuthentication = BiometricDeviceAuthentication(biometricManager, biometricPrompt, promptInfo)
}
override fun onResume() {
super.onResume()
viewModel.refresh()
}
private inner class AuthListener : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationFailed() {
Log.w(TAG, "onAuthenticationFailed")
Toast.makeText(requireContext(), R.string.RemoteBackupsSettingsFragment__authenticatino_required, Toast.LENGTH_SHORT).show()
}
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
Log.i(TAG, "onAuthenticationSucceeded")
displayBackupKey()
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
Log.w(TAG, "onAuthenticationError: $errorCode, $errString")
onAuthenticationFailed()
}
}
}
/**
@@ -422,6 +364,15 @@ private fun RemoteBackupsSettingsContent(
backupProgress: ArchiveUploadProgressState?,
statusBarColorNestedScrollConnection: StatusBarColorNestedScrollConnection?
) {
val context = LocalContext.current
val biometrics = rememberBiometricsAuthentication(
promptTitle = stringResource(R.string.RemoteBackupsSettingsFragment__unlock_to_view_backup_key),
educationSheetMessage = stringResource(R.string.RemoteBackupsSettingsFragment__to_view_your_key),
onAuthenticationFailed = {
Toast.makeText(context, R.string.RemoteBackupsSettingsFragment__authenticatino_required, Toast.LENGTH_SHORT).show()
}
)
val snackbarHostState = remember {
SnackbarHostState()
}
@@ -545,7 +496,8 @@ private fun RemoteBackupsSettingsContent(
state = state,
backupRestoreState = backupRestoreState,
backupProgress = backupProgress,
contentCallbacks = contentCallbacks
contentCallbacks = contentCallbacks,
biometrics = biometrics
)
} else {
if (state.backupCreationError != null) {
@@ -883,7 +835,8 @@ private fun LazyListScope.appendBackupDetailsItems(
state: RemoteBackupsSettingsState,
backupRestoreState: BackupRestoreState,
backupProgress: ArchiveUploadProgressState?,
contentCallbacks: ContentCallbacks
contentCallbacks: ContentCallbacks,
biometrics: BiometricsAuthentication
) {
item {
Dividers.Default()
@@ -984,7 +937,7 @@ private fun LazyListScope.appendBackupDetailsItems(
item {
Rows.TextRow(
text = stringResource(R.string.RemoteBackupsSettingsFragment__view_backup_key),
onClick = contentCallbacks::onViewBackupKeyClick,
onClick = { biometrics.withBiometricsAuthentication { contentCallbacks.onViewBackupKeyClick() } },
enabled = state.canViewBackupKey
)
}

View File

@@ -79,10 +79,6 @@ class ChatsSettingsFragment : ComposeFragment() {
override fun onEnterKeySendsChanged(enabled: Boolean) {
viewModel.setEnterKeySends(enabled)
}
override fun onChatBackupsClick() {
findNavController().safeNavigate(R.id.action_chatsSettingsFragment_to_backupsPreferenceFragment)
}
}
}
@@ -95,7 +91,6 @@ private interface ChatsSettingsCallbacks {
fun onAddOrEditFoldersClick() = Unit
fun onUseSystemEmojiChanged(enabled: Boolean) = Unit
fun onEnterKeySendsChanged(enabled: Boolean) = Unit
fun onChatBackupsClick() = Unit
object Empty : ChatsSettingsCallbacks
}

View File

@@ -64,7 +64,7 @@ sealed interface AppSettingsRoute : Parcelable {
@Parcelize
sealed interface BackupsRoute : AppSettingsRoute {
data object Backups : BackupsRoute
data object Local : BackupsRoute
data class Local(val triggerUpdateFlow: Boolean = false) : BackupsRoute
data class Remote(val backupLaterSelected: Boolean = false, val forQuickRestore: Boolean = false) : BackupsRoute
data object DisplayKey : BackupsRoute
}