diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt index 82e6f33d12..b9303f39b1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt @@ -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 { + 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. */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/MessageBackupTier.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/MessageBackupTier.kt index 9111820986..7a3b3efa49 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/MessageBackupTier.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/MessageBackupTier.kt @@ -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 { 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 + } + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/AccountDataArchiveProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/AccountDataArchiveProcessor.kt index e8d2fcd25e..eb9551f0e8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/AccountDataArchiveProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/AccountDataArchiveProcessor.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt index bcd839b1dd..58bca577c4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt @@ -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 { 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 cb263e7eef..54af55effb 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 @@ -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() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupsSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupsSettingsState.kt index fb79fd6bd1..97ded5d8b4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupsSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupsSettingsState.kt @@ -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 ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupsSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupsSettingsViewModel.kt index f9f1ecfe1f..6d4cc097c5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupsSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupsSettingsViewModel.kt @@ -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?) { 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 0c5fa895b6..b6e4503930 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 @@ -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)) ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsState.kt index 68ba1b126b..08b46c9834 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsState.kt @@ -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( diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsViewModel.kt index 0020cc63ce..f68f0463c7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsViewModel.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncHelper.kt b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncHelper.kt index 3e5358fbe4..7d483f0291 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncHelper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncHelper.kt @@ -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) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e1479b729d..87b1c4701a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8753,6 +8753,8 @@ Subscription canceled Automatic backups with Signal\'s secure end-to-end encrypted storage service. + + Automatic backups with Signal\'s secure end-to-end encrypted storage service. Get started on your phone. "Subscription not found on this device." @@ -8856,6 +8858,16 @@ %1$s at %2$s Never + + Your phone ยท %1$s + + You can manage or upgrade your Signal Secure Backups subscription on your primary device. + + You can manage or cancel your Signal Secure Backups subscription on your primary device. + + You can manage or renew your Signal Secure Backups subscription on your primary device. + + You can manage or upgrade Signal Secure Backups on your primary device. Back up now