Show backups information on linked devices and allow on-device backups.

This commit is contained in:
Cody Henthorne
2026-07-15 13:47:57 -04:00
committed by Greyson Parrelli
parent df4afe4e92
commit 4f6c1c4647
12 changed files with 431 additions and 129 deletions
@@ -1675,6 +1675,19 @@ object BackupRepository {
}
}
/**
* Stores the remote backup's last-modified time in [BackupValues.lastBackupTime], (404/401 clear it to 0).
*/
fun refreshBackupFileTimestamp(): NetworkResult<ZonedDateTime> {
return getBackupFileLastModified().also { result ->
when (result) {
is NetworkResult.Success -> SignalStore.backup.lastBackupTime = result.result.toMillis()
is NetworkResult.StatusCodeError if (result.code == 404 || result.code == 401) -> SignalStore.backup.lastBackupTime = 0L
else -> Log.w(TAG, "Failed to refresh last backup time from remote: ${result::class.simpleName}")
}
}
}
/**
* Returns an object with details about the remote backup state.
*/
@@ -6,6 +6,7 @@
package org.thoughtcrime.securesms.backup.v2
import org.signal.core.util.LongSerializer
import org.signal.libsignal.zkgroup.backups.BackupLevel
/**
* Serializable enum value for what we think a user's current backup tier is.
@@ -18,6 +19,13 @@ enum class MessageBackupTier(val value: Int) {
FREE(0),
PAID(1);
fun toBackupLevel(): Long {
return when (this) {
FREE -> BackupLevel.FREE.value.toLong()
PAID -> BackupLevel.PAID.value.toLong()
}
}
companion object Serializer : LongSerializer<MessageBackupTier?> {
override fun serialize(data: MessageBackupTier?): Long {
return data?.value?.toLong() ?: -1
@@ -26,5 +34,13 @@ enum class MessageBackupTier(val value: Int) {
override fun deserialize(data: Long): MessageBackupTier? {
return entries.firstOrNull { it.value == data.toInt() }
}
fun fromBackupLevel(backupLevel: Long?): MessageBackupTier? {
return when (backupLevel) {
BackupLevel.FREE.value.toLong() -> FREE
BackupLevel.PAID.value.toLong() -> PAID
else -> null
}
}
}
}
@@ -16,7 +16,6 @@ import org.signal.core.models.database.AttachmentId
import org.signal.core.util.UuidUtil
import org.signal.core.util.logging.Log
import org.signal.core.util.toByteArray
import org.signal.libsignal.zkgroup.backups.BackupLevel
import org.signal.mediasend.SentMediaQuality
import org.thoughtcrime.securesms.backup.v2.ExportState
import org.thoughtcrime.securesms.backup.v2.ImportState
@@ -127,7 +126,7 @@ object AccountDataArchiveProcessor {
hasCompletedUsernameOnboarding = signalStore.uiHintValues.hasCompletedUsernameOnboarding(),
customChatColors = db.chatColorsTable.getSavedChatColors().toRemoteChatColors().also { colors -> exportState.customChatColorIds.addAll(colors.map { it.id }) },
optimizeOnDeviceStorage = signalStore.backupValues.optimizeStorage && signalStore.backupValues.backupTier == MessageBackupTier.PAID,
backupTier = signalStore.backupValues.backupTier.toRemoteBackupTier(),
backupTier = signalStore.backupValues.backupTier?.toBackupLevel(),
defaultSentMediaQuality = signalStore.settingsValues.sentMediaQuality.toRemoteSentMediaQuality(),
autoDownloadSettings = AccountData.AutoDownloadSettings(
images = getRemoteAutoDownloadOption("image", mobileAutoDownload, wifiAutoDownload),
@@ -277,7 +276,7 @@ object AccountDataArchiveProcessor {
SignalStore.story.userHasSeenGroupStoryEducationSheet = settings.hasSeenGroupStoryEducationSheet
SignalStore.story.viewedReceiptsEnabled = settings.storyViewReceiptsEnabled ?: settings.readReceipts
SignalStore.backup.optimizeStorage = settings.optimizeOnDeviceStorage
SignalStore.backup.backupTier = settings.backupTier?.toLocalBackupTier()
SignalStore.backup.backupTier = MessageBackupTier.fromBackupLevel(settings.backupTier)
SignalStore.settings.sentMediaQuality = settings.defaultSentMediaQuality.toLocalSentMediaQuality()
SignalStore.settings.setTheme(settings.appTheme.toLocalTheme())
SignalStore.settings.setCallDataMode(settings.callsUseLessDataSetting.toLocalCallDataMode())
@@ -456,22 +455,6 @@ object AccountDataArchiveProcessor {
}
}
private fun MessageBackupTier?.toRemoteBackupTier(): Long? {
return when (this) {
MessageBackupTier.FREE -> BackupLevel.FREE.value.toLong()
MessageBackupTier.PAID -> BackupLevel.PAID.value.toLong()
null -> null
}
}
private fun Long?.toLocalBackupTier(): MessageBackupTier? {
return when (this) {
BackupLevel.FREE.value.toLong() -> MessageBackupTier.FREE
BackupLevel.PAID.value.toLong() -> MessageBackupTier.PAID
else -> null
}
}
private fun SentMediaQuality.toRemoteSentMediaQuality(): AccountData.SentMediaQuality {
return when (this) {
SentMediaQuality.STANDARD -> AccountData.SentMediaQuality.STANDARD
@@ -417,20 +417,20 @@ private fun AppSettingsContent(
)
}
if (state.isPrimaryDevice) {
item {
Rows.TextRow(
icon = SignalIcons.Backup.imageVector,
text = stringResource(R.string.preferences_chats__backups),
onClick = {
callbacks.navigate(AppSettingsRoute.BackupsRoute.Backups())
},
onLongClick = {
callbacks.copyRemoteBackupsSubscriberIdToClipboard()
},
enabled = isRegisteredAndUpToDate
)
}
item {
Rows.TextRow(
icon = SignalIcons.Backup.imageVector,
text = stringResource(R.string.preferences_chats__backups),
onClick = {
callbacks.navigate(AppSettingsRoute.BackupsRoute.Backups())
},
onLongClick = if (state.isPrimaryDevice) {
{ callbacks.copyRemoteBackupsSubscriberIdToClipboard() }
} else {
null
},
enabled = isRegisteredAndUpToDate
)
}
item {
@@ -35,6 +35,11 @@ import androidx.compose.ui.platform.LocalLocale
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.buildAnnotatedString
import androidx.compose.ui.text.withLink
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -58,6 +63,7 @@ import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
import org.thoughtcrime.securesms.components.settings.app.subscription.MessageBackupsCheckoutLauncher.createBackupsCheckoutLauncher
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.navigation.safeNavigate
@@ -86,7 +92,7 @@ class BackupsSettingsFragment : ComposeFragment() {
findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_remoteBackupsSettingsFragment)
}
if (savedInstanceState == null && args.launchCheckoutFlow) {
if (savedInstanceState == null && args.launchCheckoutFlow && SignalStore.account.isPrimaryDevice) {
checkoutLauncher.launch(null)
}
}
@@ -103,7 +109,9 @@ class BackupsSettingsFragment : ComposeFragment() {
is BackupState.Error -> Unit
BackupState.None -> {
checkoutLauncher.launch(null)
if (!state.isLinkedDevice) {
checkoutLauncher.launch(null)
}
}
else -> {
@@ -112,13 +120,14 @@ class BackupsSettingsFragment : ComposeFragment() {
}
},
onOnDeviceBackupsRowClick = {
if (SignalStore.backup.newLocalBackupsEnabled || (Environment.Backups.isNewFormatSupportedForLocalBackup() && !SignalStore.settings.isBackupEnabled)) {
if (state.isLinkedDevice || SignalStore.backup.newLocalBackupsEnabled || (Environment.Backups.isNewFormatSupportedForLocalBackup() && !SignalStore.settings.isBackupEnabled)) {
findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_localBackupsFragment)
} else {
findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_backupsPreferenceFragment)
}
},
onBackupTierInternalOverrideChanged = { viewModel.onBackupTierInternalOverrideChanged(it) }
onBackupTierInternalOverrideChanged = { viewModel.onBackupTierInternalOverrideChanged(it) },
onLearnMoreClick = { CommunicationActions.openBrowserLink(requireContext(), "https://support.signal.org/hc/articles/360007059752") }
)
}
}
@@ -129,7 +138,8 @@ private fun BackupsSettingsContent(
onNavigationClick: () -> Unit = {},
onBackupsRowClick: () -> Unit = {},
onOnDeviceBackupsRowClick: () -> Unit = {},
onBackupTierInternalOverrideChanged: (MessageBackupTier?) -> Unit = {}
onBackupTierInternalOverrideChanged: (MessageBackupTier?) -> Unit = {},
onLearnMoreClick: () -> Unit = {}
) {
Scaffolds.Settings(
title = stringResource(R.string.preferences_chats__backups),
@@ -166,21 +176,29 @@ private fun BackupsSettingsContent(
}
item {
val displayActionButton = !backupsSettingsState.isLinkedDevice
when (backupsSettingsState.backupState) {
is BackupState.LocalStore -> {
LocalStoreBackupRow(
backupState = backupsSettingsState.backupState,
lastBackupAt = backupsSettingsState.lastBackupAt,
onBackupsRowClick = onBackupsRowClick
onBackupsRowClick = onBackupsRowClick,
displayActionButton = displayActionButton
)
OtherWaysToBackUpHeading()
}
is BackupState.Inactive -> {
InactiveBackupsRow(
onBackupsRowClick = onBackupsRowClick
)
if (backupsSettingsState.isLinkedDevice) {
BackupsOffLinkedDeviceRow(onLearnMoreClick = onLearnMoreClick)
} else {
InactiveBackupsRow(
onBackupsRowClick = onBackupsRowClick,
displayActionButton = true
)
}
OtherWaysToBackUpHeading()
}
@@ -189,23 +207,30 @@ private fun BackupsSettingsContent(
ActiveBackupsRow(
backupState = backupsSettingsState.backupState,
onBackupsRowClick = onBackupsRowClick,
lastBackupAt = backupsSettingsState.lastBackupAt
lastBackupAt = backupsSettingsState.lastBackupAt,
displayActionButton = true
)
OtherWaysToBackUpHeading()
}
BackupState.None -> {
NeverEnabledBackupsRow(
onBackupsRowClick = onBackupsRowClick
)
if (backupsSettingsState.isLinkedDevice) {
BackupsOffLinkedDeviceRow(onLearnMoreClick = onLearnMoreClick)
} else {
NeverEnabledBackupsRow(
onBackupsRowClick = onBackupsRowClick,
displayActionButton = true
)
}
OtherWaysToBackUpHeading()
}
is BackupState.Error -> {
WaitingForNetworkRow(
onBackupsRowClick = onBackupsRowClick
onBackupsRowClick = onBackupsRowClick,
displayActionButton = displayActionButton
)
OtherWaysToBackUpHeading()
@@ -213,7 +238,8 @@ private fun BackupsSettingsContent(
BackupState.NotFound -> {
NotFoundBackupRow(
onBackupsRowClick = onBackupsRowClick
onBackupsRowClick = onBackupsRowClick,
displayActionButton = displayActionButton
)
OtherWaysToBackUpHeading()
@@ -221,7 +247,8 @@ private fun BackupsSettingsContent(
is BackupState.Pending -> {
PendingBackupRow(
onBackupsRowClick = onBackupsRowClick
onBackupsRowClick = onBackupsRowClick,
displayActionButton = displayActionButton
)
OtherWaysToBackUpHeading()
@@ -231,7 +258,8 @@ private fun BackupsSettingsContent(
ActiveBackupsRow(
backupState = backupsSettingsState.backupState,
lastBackupAt = backupsSettingsState.lastBackupAt,
onBackupsRowClick = onBackupsRowClick
onBackupsRowClick = onBackupsRowClick,
displayActionButton = displayActionButton
)
OtherWaysToBackUpHeading()
@@ -262,7 +290,8 @@ private fun OtherWaysToBackUpHeading() {
@Composable
private fun NeverEnabledBackupsRow(
onBackupsRowClick: () -> Unit = {}
onBackupsRowClick: () -> Unit = {},
displayActionButton: Boolean = true
) {
Rows.TextRow(
modifier = Modifier.wrapContentHeight(),
@@ -291,13 +320,15 @@ private fun NeverEnabledBackupsRow(
style = MaterialTheme.typography.bodyMedium
)
Buttons.MediumTonal(
onClick = onBackupsRowClick,
modifier = Modifier.padding(top = 12.dp)
) {
Text(
text = stringResource(R.string.BackupsSettingsFragment_set_up)
)
if (displayActionButton) {
Buttons.MediumTonal(
onClick = onBackupsRowClick,
modifier = Modifier.padding(top = 12.dp)
) {
Text(
text = stringResource(R.string.BackupsSettingsFragment_set_up)
)
}
}
}
}
@@ -305,12 +336,57 @@ private fun NeverEnabledBackupsRow(
}
@Composable
private fun WaitingForNetworkRow(onBackupsRowClick: () -> Unit = {}) {
private fun BackupsOffLinkedDeviceRow(
onLearnMoreClick: () -> Unit = {}
) {
val description = buildAnnotatedString {
append(stringResource(R.string.BackupsSettingsFragment__automatic_backups_get_started_on_your_phone))
append(" ")
withLink(LinkAnnotation.Clickable(tag = "learn-more") { onLearnMoreClick() }) {
withStyle(SpanStyle(color = MaterialTheme.colorScheme.primary)) {
append(stringResource(R.string.RemoteBackupsSettingsFragment__learn_more))
}
}
}
Rows.TextRow(
modifier = Modifier.wrapContentHeight(),
icon = {
Box(
modifier = Modifier
.padding(top = 12.dp)
.align(Alignment.Top)
) {
Icon(
painter = SignalIcons.Backup.painter,
contentDescription = null
)
}
},
text = {
Column {
Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__signal_backups),
style = MaterialTheme.typography.bodyLarge
)
Text(
text = description,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium
)
}
}
)
}
@Composable
private fun WaitingForNetworkRow(onBackupsRowClick: () -> Unit = {}, displayActionButton: Boolean = true) {
Rows.TextRow(
text = {
Column {
Text(text = stringResource(R.string.RemoteBackupsSettingsFragment__waiting_for_network))
ViewSettingsButton(onBackupsRowClick)
ViewSettingsButton(onBackupsRowClick, displayActionButton)
}
},
icon = {
@@ -321,7 +397,8 @@ private fun WaitingForNetworkRow(onBackupsRowClick: () -> Unit = {}) {
@Composable
private fun InactiveBackupsRow(
onBackupsRowClick: () -> Unit = {}
onBackupsRowClick: () -> Unit = {},
displayActionButton: Boolean = true
) {
Rows.TextRow(
text = {
@@ -336,7 +413,7 @@ private fun InactiveBackupsRow(
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
ViewSettingsButton(onBackupsRowClick)
ViewSettingsButton(onBackupsRowClick, displayActionButton)
}
},
icon = {
@@ -354,7 +431,8 @@ private fun InactiveBackupsRow(
@Composable
private fun NotFoundBackupRow(
onBackupsRowClick: () -> Unit = {}
onBackupsRowClick: () -> Unit = {},
displayActionButton: Boolean = true
) {
Rows.TextRow(
modifier = Modifier.wrapContentHeight(),
@@ -382,7 +460,7 @@ private fun NotFoundBackupRow(
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
ViewSettingsButton(onBackupsRowClick)
ViewSettingsButton(onBackupsRowClick, displayActionButton)
}
}
)
@@ -390,7 +468,8 @@ private fun NotFoundBackupRow(
@Composable
private fun PendingBackupRow(
onBackupsRowClick: () -> Unit = {}
onBackupsRowClick: () -> Unit = {},
displayActionButton: Boolean = true
) {
Rows.TextRow(
modifier = Modifier.wrapContentHeight(),
@@ -418,14 +497,18 @@ private fun PendingBackupRow(
style = MaterialTheme.typography.bodyMedium
)
ViewSettingsButton(onBackupsRowClick)
ViewSettingsButton(onBackupsRowClick, displayActionButton)
}
}
)
}
@Composable
private fun ViewSettingsButton(onClick: () -> Unit) {
private fun ViewSettingsButton(onClick: () -> Unit, visible: Boolean = true) {
if (!visible) {
return
}
Buttons.MediumTonal(
onClick = onClick,
modifier = Modifier.padding(top = 12.dp)
@@ -440,7 +523,8 @@ private fun ViewSettingsButton(onClick: () -> Unit) {
private fun LocalStoreBackupRow(
backupState: BackupState.LocalStore,
lastBackupAt: Duration,
onBackupsRowClick: () -> Unit
onBackupsRowClick: () -> Unit,
displayActionButton: Boolean = true
) {
Rows.TextRow(
modifier = Modifier.wrapContentHeight(),
@@ -475,7 +559,7 @@ private fun LocalStoreBackupRow(
)
LastBackedUpText(lastBackupAt)
ViewSettingsButton(onBackupsRowClick)
ViewSettingsButton(onBackupsRowClick, displayActionButton)
}
}
)
@@ -485,7 +569,8 @@ private fun LocalStoreBackupRow(
private fun ActiveBackupsRow(
backupState: BackupState.WithTypeAndRenewalTime,
lastBackupAt: Duration,
onBackupsRowClick: () -> Unit = {}
onBackupsRowClick: () -> Unit = {},
displayActionButton: Boolean = true
) {
Rows.TextRow(
modifier = Modifier.wrapContentHeight(),
@@ -552,7 +637,7 @@ private fun ActiveBackupsRow(
LastBackedUpText(lastBackupAt)
ViewSettingsButton(onBackupsRowClick)
ViewSettingsButton(onBackupsRowClick, displayActionButton)
}
}
)
@@ -644,7 +729,8 @@ private fun BackupsSettingsContentPreview() {
renewalTime = 0.seconds,
price = FiatMoney(BigDecimal.valueOf(4), Currency.getInstance("CAD"))
),
lastBackupAt = 0.seconds
lastBackupAt = 0.seconds,
isLinkedDevice = false
)
)
}
@@ -652,14 +738,21 @@ private fun BackupsSettingsContentPreview() {
@DayNightPreviews
@Composable
private fun BackupsSettingsContentBackupTierInternalOverridePreview() {
private fun BackupsSettingsContentLinkedDevicePreview() {
Previews.Preview {
BackupsSettingsContent(
backupsSettingsState = BackupsSettingsState(
backupState = BackupState.None,
showBackupTierInternalOverride = true,
backupTierInternalOverride = null,
lastBackupAt = 0.seconds
backupState = BackupState.ActivePaid(
messageBackupsType = MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal.valueOf(2.99), Currency.getInstance("USD")),
storageAllowanceBytes = 1_000_000,
mediaTtl = 30.days
),
renewalTime = 0.seconds,
price = FiatMoney(BigDecimal.valueOf(2.99), Currency.getInstance("USD"))
),
lastBackupAt = 0.seconds,
isLinkedDevice = true
)
)
}
@@ -758,3 +851,11 @@ private fun NeverEnabledBackupsRowPreview() {
NeverEnabledBackupsRow()
}
}
@DayNightPreviews
@Composable
private fun BackupsOffLinkedDeviceRowPreview() {
Previews.Preview {
BackupsOffLinkedDeviceRow()
}
}
@@ -17,5 +17,6 @@ data class BackupsSettingsState(
val backupState: BackupState,
val lastBackupAt: Duration = SignalStore.backup.lastBackupTime.milliseconds,
val showBackupTierInternalOverride: Boolean = false,
val backupTierInternalOverride: MessageBackupTier? = null
val backupTierInternalOverride: MessageBackupTier? = null,
val isLinkedDevice: Boolean = SignalStore.account.isLinkedDevice
)
@@ -15,6 +15,7 @@ import kotlinx.coroutines.launch
import org.signal.core.util.concurrent.SignalDispatchers
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.backup.DeletionState
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -44,7 +45,7 @@ class BackupsSettingsViewModel : ViewModel() {
it.copy(
backupState = enabledState,
lastBackupAt = SignalStore.backup.lastBackupTime.milliseconds,
showBackupTierInternalOverride = Environment.IS_STAGING,
showBackupTierInternalOverride = Environment.IS_STAGING && SignalStore.account.isPrimaryDevice,
backupTierInternalOverride = SignalStore.backup.backupTierInternalOverride
)
}
@@ -59,6 +60,12 @@ class BackupsSettingsViewModel : ViewModel() {
}
}
}
if (SignalStore.account.isLinkedDevice) {
viewModelScope.launch(Dispatchers.IO) {
BackupRepository.refreshBackupFileTimestamp()
}
}
}
fun onBackupTierInternalOverrideChanged(tier: MessageBackupTier?) {
@@ -452,7 +452,8 @@ private fun RemoteBackupsSettingsContent(
state = state.backupState,
onLearnMoreClick = contentCallbacks::onLearnMoreAboutLostSubscription,
onRenewClick = contentCallbacks::onRenewLostSubscription,
isRenewEnabled = backupDeleteState.isIdle()
isRenewEnabled = backupDeleteState.isIdle(),
isLinkedDevice = state.isLinkedDevice
)
}
@@ -464,7 +465,8 @@ private fun RemoteBackupsSettingsContent(
onBackupTypeActionButtonClicked = contentCallbacks::onBackupTypeActionClick,
isPaidTierPricingAvailable = state.isPaidTierPricingAvailable,
isGooglePlayServicesAvailable = state.isGooglePlayServicesAvailable,
buttonsEnabled = backupDeleteState.isIdle()
buttonsEnabled = backupDeleteState.isIdle(),
isLinkedDevice = state.isLinkedDevice
)
}
@@ -473,13 +475,16 @@ private fun RemoteBackupsSettingsContent(
title = stringResource(R.string.RemoteBackupsSettingsFragment__your_subscription_was_not_found),
onRenewClick = contentCallbacks::onRenewLostSubscription,
onLearnMoreClick = contentCallbacks::onLearnMoreAboutLostSubscription,
isRenewEnabled = backupDeleteState.isIdle()
isRenewEnabled = backupDeleteState.isIdle(),
isLinkedDevice = state.isLinkedDevice
)
}
}
}
if (backupDeleteState != DeletionState.NONE && backupDeleteState != DeletionState.CLEAR_LOCAL_STATE) {
if (state.isLinkedDevice) {
appendReducedBackupDetailsItems(state)
} else if (backupDeleteState != DeletionState.NONE && backupDeleteState != DeletionState.CLEAR_LOCAL_STATE) {
appendBackupDeletionItems(
backupDeleteState = backupDeleteState,
backupRestoreState = backupRestoreState,
@@ -973,12 +978,58 @@ private fun LazyListScope.appendBackupDetailsItems(
}
}
private fun LazyListScope.appendReducedBackupDetailsItems(
state: RemoteBackupsSettingsState
) {
item {
Dividers.Default()
}
item {
Texts.SectionHeader(text = stringResource(id = R.string.RemoteBackupsSettingsFragment__backup_details))
}
item {
ReadOnlyLastBackupRow(lastBackupTimestamp = state.lastBackupTimestamp)
}
}
@Composable
private fun ReadOnlyLastBackupRow(
lastBackupTimestamp: Long
) {
val label = if (lastBackupTimestamp > 0) {
val context = LocalContext.current
val day = remember(lastBackupTimestamp) {
DateUtils.getDayPrecisionTimeString(context, Locale.getDefault(), lastBackupTimestamp)
}
val time = remember(lastBackupTimestamp) {
DateUtils.getOnlyTimeString(context, lastBackupTimestamp)
}
stringResource(
id = R.string.RemoteBackupsSettingsFragment__your_phone_s,
stringResource(id = R.string.RemoteBackupsSettingsFragment__s_at_s, day, time)
)
} else {
stringResource(id = R.string.RemoteBackupsSettingsFragment__never)
}
Rows.TextRow(
text = stringResource(id = R.string.RemoteBackupsSettingsFragment__last_backup),
label = label
)
}
@Composable
private fun BackupCard(
backupState: BackupState.WithTypeAndRenewalTime,
isPaidTierPricingAvailable: Boolean,
isGooglePlayServicesAvailable: Boolean,
buttonsEnabled: Boolean,
isLinkedDevice: Boolean = false,
onBackupTypeActionButtonClicked: (MessageBackupTier) -> Unit = {}
) {
val messageBackupsType = backupState.messageBackupsType
@@ -1070,7 +1121,21 @@ private fun BackupCard(
)
}
if (backupState.isActive() && isPaidTierPricingAvailable && isGooglePlayServicesAvailable) {
if (isLinkedDevice) {
val primaryDeviceText = when (backupState) {
is BackupState.ActivePaid -> stringResource(R.string.RemoteBackupsSettingsFragment__you_can_manage_or_cancel_your_subscription_on_your_primary_device)
is BackupState.Canceled -> stringResource(R.string.RemoteBackupsSettingsFragment__you_can_manage_or_renew_your_subscription_on_your_primary_device)
is BackupState.ActiveFree -> stringResource(R.string.RemoteBackupsSettingsFragment__you_can_manage_or_upgrade_your_subscription_on_your_primary_device)
else -> stringResource(R.string.RemoteBackupsSettingsFragment__you_can_manage_or_upgrade_backups_on_your_primary_device)
}
Text(
text = primaryDeviceText,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 12.dp)
)
} else if (backupState.isActive() && isPaidTierPricingAvailable && isGooglePlayServicesAvailable) {
val buttonText = when (messageBackupsType) {
is MessageBackupsType.Paid -> stringResource(R.string.RemoteBackupsSettingsFragment__manage_or_cancel)
is MessageBackupsType.Free -> stringResource(R.string.RemoteBackupsSettingsFragment__upgrade)
@@ -1270,6 +1335,7 @@ private fun PendingCard(
private fun SubscriptionNotFoundCard(
title: String,
isRenewEnabled: Boolean,
isLinkedDevice: Boolean = false,
onRenewClick: () -> Unit = {},
onLearnMoreClick: () -> Unit = {}
) {
@@ -1308,38 +1374,47 @@ private fun SubscriptionNotFoundCard(
}
}
Row(
horizontalArrangement = spacedBy(16.dp)
) {
Buttons.MediumTonal(
onClick = onRenewClick,
colors = ButtonDefaults.filledTonalButtonColors().copy(
containerColor = SignalTheme.colors.colorTransparent5,
contentColor = colorResource(CoreUiR.color.signal_light_colorOnSurface)
),
modifier = Modifier
.padding(top = 24.dp)
.weight(1f)
if (isLinkedDevice) {
Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__you_can_manage_or_upgrade_backups_on_your_primary_device),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 12.dp)
)
} else {
Row(
horizontalArrangement = spacedBy(16.dp)
) {
Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__renew)
)
}
Buttons.MediumTonal(
onClick = onRenewClick,
colors = ButtonDefaults.filledTonalButtonColors().copy(
containerColor = SignalTheme.colors.colorTransparent5,
contentColor = colorResource(CoreUiR.color.signal_light_colorOnSurface)
),
modifier = Modifier
.padding(top = 24.dp)
.weight(1f)
) {
Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__renew)
)
}
Buttons.MediumTonal(
onClick = onLearnMoreClick,
enabled = isRenewEnabled,
colors = ButtonDefaults.filledTonalButtonColors().copy(
containerColor = SignalTheme.colors.colorTransparent5,
contentColor = colorResource(CoreUiR.color.signal_light_colorOnSurface)
),
modifier = Modifier
.padding(top = 24.dp)
.weight(1f)
) {
Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__learn_more)
)
Buttons.MediumTonal(
onClick = onLearnMoreClick,
enabled = isRenewEnabled,
colors = ButtonDefaults.filledTonalButtonColors().copy(
containerColor = SignalTheme.colors.colorTransparent5,
contentColor = colorResource(CoreUiR.color.signal_light_colorOnSurface)
),
modifier = Modifier
.padding(top = 24.dp)
.weight(1f)
) {
Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__learn_more)
)
}
}
}
}
@@ -1349,6 +1424,7 @@ private fun SubscriptionNotFoundCard(
private fun SubscriptionMismatchMissingGooglePlayCard(
state: BackupState.SubscriptionMismatchMissingGooglePlay,
isRenewEnabled: Boolean,
isLinkedDevice: Boolean = false,
onRenewClick: () -> Unit = {},
onLearnMoreClick: () -> Unit = {}
) {
@@ -1357,6 +1433,7 @@ private fun SubscriptionMismatchMissingGooglePlayCard(
SubscriptionNotFoundCard(
title = pluralStringResource(R.plurals.RemoteBackupsSettingsFragment__your_subscription_on_this_device_is_valid, days.toInt(), days),
isRenewEnabled = isRenewEnabled,
isLinkedDevice = isLinkedDevice,
onRenewClick = onRenewClick,
onLearnMoreClick = onLearnMoreClick
)
@@ -1847,6 +1924,64 @@ private fun RemoteBackupsSettingsInternalUserContentPreview() {
}
}
@DayNightPreviews
@Composable
private fun RemoteBackupsSettingsLinkedDevicePaidContentPreview() {
Previews.Preview {
RemoteBackupsSettingsContent(
state = RemoteBackupsSettingsState(
backupsEnabled = true,
isLinkedDevice = true,
lastBackupTimestamp = -1,
canBackUpUsingCellular = false,
canRestoreUsingCellular = false,
dialog = RemoteBackupsSettingsState.Dialog.NONE,
snackbar = RemoteBackupsSettingsState.Snackbar.NONE,
backupMediaSize = 2300000,
backupState = BackupState.ActivePaid(
messageBackupsType = MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal.valueOf(2.99), Currency.getInstance("USD")),
storageAllowanceBytes = 1_000_000,
mediaTtl = 30.days
),
price = FiatMoney(BigDecimal.valueOf(2.99), Currency.getInstance("USD")),
renewalTime = 1_752_710_400.seconds
)
),
statusBarColorNestedScrollConnection = null,
backupDeleteState = DeletionState.NONE,
backupRestoreState = BackupRestoreState.None,
contentCallbacks = ContentCallbacks.Empty,
backupProgress = null
)
}
}
@DayNightPreviews
@Composable
private fun RemoteBackupsSettingsLinkedDeviceNotFoundContentPreview() {
Previews.Preview {
RemoteBackupsSettingsContent(
state = RemoteBackupsSettingsState(
backupsEnabled = true,
isLinkedDevice = true,
lastBackupTimestamp = -1,
canBackUpUsingCellular = false,
canRestoreUsingCellular = false,
dialog = RemoteBackupsSettingsState.Dialog.NONE,
snackbar = RemoteBackupsSettingsState.Snackbar.NONE,
backupMediaSize = 2300000,
backupState = BackupState.NotFound
),
statusBarColorNestedScrollConnection = null,
backupDeleteState = DeletionState.NONE,
backupRestoreState = BackupRestoreState.None,
contentCallbacks = ContentCallbacks.Empty,
backupProgress = null
)
}
}
@DayNightPreviews
@Composable
private fun RedemptionErrorAlertPreview() {
@@ -1875,10 +2010,8 @@ private fun ErrorCardPreview() {
@Composable
private fun PendingCardPreview() {
Previews.Preview {
val locale = LocalLocale.current.platformLocale
PendingCard(
price = FiatMoney(BigDecimal.TEN, Currency.getInstance(locale))
price = FiatMoney(BigDecimal.TEN, Currency.getInstance(Locale.US))
)
}
}
@@ -35,7 +35,8 @@ data class RemoteBackupsSettingsState(
val backupCreationError: BackupValues.BackupCreationError? = null,
val lastMessageCutoffTime: Long = 0,
val freeTierMediaRetentionDays: Int = -1,
val isGooglePlayServicesAvailable: Boolean = false
val isGooglePlayServicesAvailable: Boolean = false,
val isLinkedDevice: Boolean = false
) {
data class BackupMediaDetails(
@@ -79,7 +79,8 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
internalUser = RemoteConfig.internalUser,
includeDebuglog = SignalStore.internal.includeDebuglogInBackup.takeIf { RemoteConfig.internalUser },
backupCreationError = SignalStore.backup.backupCreationError,
lastMessageCutoffTime = SignalStore.backup.lastUsedMessageCutoffTime
lastMessageCutoffTime = SignalStore.backup.lastUsedMessageCutoffTime,
isLinkedDevice = SignalStore.account.isLinkedDevice
)
)
@@ -92,6 +93,14 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
private var forQuickRestore = false
init {
if (state.value.isLinkedDevice) {
initLinkedDevice()
} else {
initPrimaryDevice()
}
}
private fun initPrimaryDevice() {
ArchiveUploadProgress.triggerUpdate()
viewModelScope.launch(Dispatchers.IO) {
@@ -199,6 +208,29 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
}
}
/**
* Render remote backups as read-only and refresh the last-backup time from the CDN.
*/
private fun initLinkedDevice() {
viewModelScope.launch(Dispatchers.IO) {
BackupStateObserver(viewModelScope, useDatabaseFallbackOnNetworkError = true).backupState.collect { backupState ->
_state.update {
it.copy(backupState = backupState)
}
}
}
viewModelScope.launch(Dispatchers.Default) {
SignalStore.backup.lastBackupTimeFlow.collect { lastBackupTime ->
_state.update { it.copy(lastBackupTimestamp = lastBackupTime) }
}
}
viewModelScope.launch(Dispatchers.IO) {
BackupRepository.refreshBackupFileTimestamp()
}
}
fun setCanBackUpUsingCellular(canBackUpUsingCellular: Boolean) {
SignalStore.backup.backupWithCellular = canBackUpUsingCellular
_state.update {
@@ -259,6 +291,10 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
}
fun refresh() {
if (state.value.isLinkedDevice) {
return
}
viewModelScope.launch(Dispatchers.IO) {
val id = SignalDatabase.inAppPayments.getLatestInAppPaymentByType(InAppPaymentType.RECURRING_BACKUP)?.id
@@ -183,8 +183,8 @@ object StorageSyncHelper {
backupTier = when {
SignalStore.account.isLinkedDevice -> null
SignalStore.backup.areBackupsEnabled && SignalStore.backup.backupTier != null -> getBackupLevelValue(SignalStore.backup.backupTier!!)
SignalStore.backup.backupTierInternalOverride != null -> getBackupLevelValue(SignalStore.backup.backupTierInternalOverride!!)
SignalStore.backup.areBackupsEnabled && SignalStore.backup.backupTier != null -> SignalStore.backup.backupTier!!.toBackupLevel()
SignalStore.backup.backupTierInternalOverride != null -> SignalStore.backup.backupTierInternalOverride!!.toBackupLevel()
else -> null
}
@@ -213,14 +213,6 @@ object StorageSyncHelper {
return accountRecord.toSignalAccountRecord(StorageId.forAccount(storageId)).toSignalStorageRecord()
}
// TODO: Currently we don't have access to the private values of the BackupLevel. Update when it becomes available.
private fun getBackupLevelValue(tier: MessageBackupTier): Long {
return when (tier) {
MessageBackupTier.FREE -> 200
MessageBackupTier.PAID -> 201
}
}
private fun getNotificationProfileManualOverride(): AccountRecord.NotificationProfileManualOverride? {
val profile = SignalDatabase.notificationProfiles.getProfile(SignalStore.notificationProfile.manuallyEnabledProfile)
return if (profile != null && profile.deletedTimestampMs == 0L) {
@@ -296,6 +288,13 @@ object StorageSyncHelper {
setSubscriber(remoteBackupsSubscriber)
}
if (SignalStore.account.isLinkedDevice) {
val remoteBackupTier = MessageBackupTier.fromBackupLevel(update.new.proto.backupTier)
if (remoteBackupTier != SignalStore.backup.backupTier) {
SignalStore.backup.backupTier = remoteBackupTier
}
}
if (update.new.proto.subscriptionManuallyCancelled && !update.old.proto.subscriptionManuallyCancelled) {
SignalStore.inAppPayments.updateLocalStateForManualCancellation(InAppPaymentSubscriberRecord.Type.DONATION)
}