diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/MessageBackupsFrequency.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/MessageBackupsFrequency.kt new file mode 100644 index 0000000000..777238b225 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/MessageBackupsFrequency.kt @@ -0,0 +1,16 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2.ui + +/** + * Describes how often a users messages are backed up. + */ +enum class MessageBackupsFrequency { + DAILY, + WEEKLY, + MONTHLY, + NEVER +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/MessageBackupsTypeFeature.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/MessageBackupsTypeFeature.kt index da608ad624..fb037d0e0a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/MessageBackupsTypeFeature.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/MessageBackupsTypeFeature.kt @@ -18,6 +18,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp +import org.signal.core.ui.Previews +import org.signal.core.ui.SignalPreview +import org.thoughtcrime.securesms.R /** * Represents a "Feature" included for a specify tier of message backups @@ -53,3 +56,16 @@ fun MessageBackupsTypeFeatureRow( ) } } + +@SignalPreview +@Composable +private fun MessageBackupsTypeFeatureRowPreview() { + Previews.Preview { + MessageBackupsTypeFeatureRow( + messageBackupsTypeFeature = MessageBackupsTypeFeature( + iconResourceId = R.drawable.symbol_edit_24, + label = "Content Label" + ) + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/MessageBackupsTypeSelectionScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/MessageBackupsTypeSelectionScreen.kt index e69242525f..c1b38f299a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/MessageBackupsTypeSelectionScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/MessageBackupsTypeSelectionScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.foundation.text.ClickableText import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -269,6 +270,7 @@ private fun formatCostPerMonth(pricePerMonth: FiatMoney): String { } } +@Stable data class MessageBackupsType( val pricePerMonth: FiatMoney, val title: String, diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/create/CreateCallLinkBottomSheetDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/create/CreateCallLinkBottomSheetDialogFragment.kt index 1be045338f..6d9a9f3a32 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/links/create/CreateCallLinkBottomSheetDialogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/links/create/CreateCallLinkBottomSheetDialogFragment.kt @@ -24,10 +24,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.core.app.ShareCompat @@ -128,19 +127,19 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment Rows.TextRow( text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__share_link_via_signal), - icon = ImageVector.vectorResource(id = R.drawable.symbol_forward_24), + icon = painterResource(id = R.drawable.symbol_forward_24), onClick = this@CreateCallLinkBottomSheetDialogFragment::onShareViaSignalClicked ) Rows.TextRow( text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__copy_link), - icon = ImageVector.vectorResource(id = R.drawable.symbol_copy_android_24), + icon = painterResource(id = R.drawable.symbol_copy_android_24), onClick = this@CreateCallLinkBottomSheetDialogFragment::onCopyLinkClicked ) Rows.TextRow( text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__share_link), - icon = ImageVector.vectorResource(id = R.drawable.symbol_share_android_24), + icon = painterResource(id = R.drawable.symbol_share_android_24), onClick = this@CreateCallLinkBottomSheetDialogFragment::onShareLinkClicked ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsFragment.kt index f0689ac88a..023f7b54f8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsFragment.kt @@ -17,10 +17,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.app.ActivityCompat @@ -287,25 +285,25 @@ private fun CallLinkDetails( Rows.TextRow( text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__share_link_via_signal), - icon = ImageVector.vectorResource(id = R.drawable.symbol_forward_24), + icon = painterResource(id = R.drawable.symbol_forward_24), onClick = callback::onShareLinkViaSignalClicked ) Rows.TextRow( text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__copy_link), - icon = ImageVector.vectorResource(id = R.drawable.symbol_copy_android_24), + icon = painterResource(id = R.drawable.symbol_copy_android_24), onClick = callback::onCopyClicked ) Rows.TextRow( text = stringResource(id = R.string.CallLinkDetailsFragment__share_link), - icon = ImageVector.vectorResource(id = R.drawable.symbol_link_24), + icon = painterResource(id = R.drawable.symbol_link_24), onClick = callback::onShareClicked ) Rows.TextRow( text = stringResource(id = R.string.CallLinkDetailsFragment__delete_call_link), - icon = ImageVector.vectorResource(id = R.drawable.symbol_trash_24), + icon = painterResource(id = R.drawable.symbol_trash_24), foregroundTint = MaterialTheme.colorScheme.error, onClick = callback::onDeleteClicked ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsFragment.kt index 216bba9715..bab62d48ca 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsFragment.kt @@ -1,12 +1,15 @@ package org.thoughtcrime.securesms.components.settings.app.chats +import android.content.Intent import androidx.lifecycle.ViewModelProvider import androidx.navigation.Navigation import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.backup.v2.ui.MessageBackupsFlowActivity import org.thoughtcrime.securesms.components.settings.DSLConfiguration import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment import org.thoughtcrime.securesms.components.settings.DSLSettingsText import org.thoughtcrime.securesms.components.settings.configure +import org.thoughtcrime.securesms.util.FeatureFlags import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter import org.thoughtcrime.securesms.util.navigation.safeNavigate @@ -81,9 +84,23 @@ class ChatsSettingsFragment : DSLSettingsFragment(R.string.preferences_chats__ch sectionHeaderPref(R.string.preferences_chats__backups) + if (FeatureFlags.messageBackups() || state.remoteBackupsEnabled) { + clickPref( + title = DSLSettingsText.from("Signal Backups"), // TODO [message-backups] -- Finalized copy + summary = DSLSettingsText.from(if (state.remoteBackupsEnabled) R.string.arrays__enabled else R.string.arrays__disabled), + onClick = { + if (state.remoteBackupsEnabled) { + Navigation.findNavController(requireView()).safeNavigate(R.id.action_chatsSettingsFragment_to_remoteBackupsSettingsFragment) + } else { + startActivity(Intent(requireContext(), MessageBackupsFlowActivity::class.java)) + } + } + ) + } + clickPref( title = DSLSettingsText.from(R.string.preferences_chats__chat_backups), - summary = DSLSettingsText.from(if (state.chatBackupsEnabled) R.string.arrays__enabled else R.string.arrays__disabled), + summary = DSLSettingsText.from(if (state.localBackupsEnabled) R.string.arrays__enabled else R.string.arrays__disabled), onClick = { Navigation.findNavController(requireView()).safeNavigate(R.id.action_chatsSettingsFragment_to_backupsPreferenceFragment) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsState.kt index f47f48d11a..8429a69eb8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsState.kt @@ -6,5 +6,6 @@ data class ChatsSettingsState( val keepMutedChatsArchived: Boolean, val useSystemEmoji: Boolean, val enterKeySends: Boolean, - val chatBackupsEnabled: Boolean + val localBackupsEnabled: Boolean, + val remoteBackupsEnabled: Boolean ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsViewModel.kt index 2062181367..87ea0a647f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsViewModel.kt @@ -22,7 +22,8 @@ class ChatsSettingsViewModel @JvmOverloads constructor( keepMutedChatsArchived = SignalStore.settings().shouldKeepMutedChatsArchived(), useSystemEmoji = SignalStore.settings().isPreferSystemEmoji, enterKeySends = SignalStore.settings().isEnterKeySends, - chatBackupsEnabled = SignalStore.settings().isBackupEnabled && BackupUtil.canUserAccessBackupDirectory(ApplicationDependencies.getApplication()) + localBackupsEnabled = SignalStore.settings().isBackupEnabled && BackupUtil.canUserAccessBackupDirectory(ApplicationDependencies.getApplication()), + remoteBackupsEnabled = SignalStore.backup().areBackupsEnabled ) ) @@ -59,8 +60,8 @@ class ChatsSettingsViewModel @JvmOverloads constructor( fun refresh() { val backupsEnabled = SignalStore.settings().isBackupEnabled && BackupUtil.canUserAccessBackupDirectory(ApplicationDependencies.getApplication()) - if (store.state.chatBackupsEnabled != backupsEnabled) { - store.update { it.copy(chatBackupsEnabled = backupsEnabled) } + if (store.state.localBackupsEnabled != backupsEnabled) { + store.update { it.copy(localBackupsEnabled = backupsEnabled) } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/backups/RemoteBackupsSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/backups/RemoteBackupsSettingsFragment.kt new file mode 100644 index 0000000000..f8d2294640 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/backups/RemoteBackupsSettingsFragment.kt @@ -0,0 +1,570 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.chats.backups + +import android.content.Intent +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.fragment.findNavController +import kotlinx.collections.immutable.persistentListOf +import org.signal.core.ui.Buttons +import org.signal.core.ui.Dialogs +import org.signal.core.ui.Dividers +import org.signal.core.ui.Previews +import org.signal.core.ui.Rows +import org.signal.core.ui.Scaffolds +import org.signal.core.ui.SignalPreview +import org.signal.core.ui.Snackbars +import org.signal.core.ui.Texts +import org.signal.core.util.money.FiatMoney +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.backup.v2.ui.MessageBackupsFlowActivity +import org.thoughtcrime.securesms.backup.v2.ui.MessageBackupsFrequency +import org.thoughtcrime.securesms.backup.v2.ui.MessageBackupsType +import org.thoughtcrime.securesms.compose.ComposeFragment +import org.thoughtcrime.securesms.payments.FiatMoneyUtil +import org.thoughtcrime.securesms.util.DateUtils +import org.thoughtcrime.securesms.util.viewModel +import java.math.BigDecimal +import java.util.Currency +import java.util.Locale + +/** + * Remote backups settings fragment. + * + * TODO [message-backups] -- All copy in this file is non-final + */ +class RemoteBackupsSettingsFragment : ComposeFragment() { + + private val viewModel by viewModel { + RemoteBackupsSettingsViewModel() + } + + @Composable + override fun FragmentContent() { + val state by viewModel.state + val callbacks = remember { Callbacks() } + + RemoteBackupsSettingsContent( + messageBackupsType = state.messageBackupsType, + lastBackupTimestamp = state.lastBackupTimestamp, + canBackUpUsingCellular = state.canBackUpUsingCellular, + backupsFrequency = state.backupsFrequency, + requestedDialog = state.dialog, + requestedSnackbar = state.snackbar, + contentCallbacks = callbacks + ) + } + + @Stable + private inner class Callbacks : ContentCallbacks { + override fun onNavigationClick() { + findNavController().popBackStack() + } + + override fun onEnableBackupsClick() { + startActivity(Intent(requireContext(), MessageBackupsFlowActivity::class.java)) + } + + override fun onBackUpUsingCellularClick(canUseCellular: Boolean) { + viewModel.setCanBackUpUsingCellular(canUseCellular) + } + + override fun onViewPaymentHistory() { + // TODO [message-backups] Navigate to payment history + } + + override fun onBackupNowClick() { + // TODO [message-backups] Enqueue immediate backup + } + + override fun onTurnOffAndDeleteBackupsClick() { + viewModel.requestDialog(RemoteBackupsSettingsState.Dialog.TURN_OFF_AND_DELETE_BACKUPS) + } + + override fun onChangeBackupFrequencyClick() { + viewModel.requestDialog(RemoteBackupsSettingsState.Dialog.BACKUP_FREQUENCY) + } + + override fun onDialogDismissed() { + viewModel.requestDialog(RemoteBackupsSettingsState.Dialog.NONE) + } + + override fun onSnackbarDismissed() { + viewModel.requestSnackbar(RemoteBackupsSettingsState.Snackbar.NONE) + } + + override fun onSelectBackupsFrequencyChange(newFrequency: MessageBackupsFrequency) { + viewModel.setBackupsFrequency(newFrequency) + } + + override fun onTurnOffAndDeleteBackupsConfirm() { + viewModel.turnOffAndDeleteBackups() + } + + override fun onChangeBackupsTypeClick() { + // TODO - launch flow at appropriate point + } + } +} + +/** + * Callback interface for RemoteBackupsSettingsContent composable. + */ +private interface ContentCallbacks { + fun onNavigationClick() = Unit + fun onEnableBackupsClick() = Unit + fun onChangeBackupsTypeClick() = Unit + fun onBackUpUsingCellularClick(canUseCellular: Boolean) = Unit + fun onViewPaymentHistory() = Unit + fun onBackupNowClick() = Unit + fun onTurnOffAndDeleteBackupsClick() = Unit + fun onChangeBackupFrequencyClick() = Unit + fun onDialogDismissed() = Unit + fun onSnackbarDismissed() = Unit + fun onSelectBackupsFrequencyChange(newFrequency: MessageBackupsFrequency) = Unit + fun onTurnOffAndDeleteBackupsConfirm() = Unit +} + +@Composable +private fun RemoteBackupsSettingsContent( + messageBackupsType: MessageBackupsType?, + lastBackupTimestamp: Long, + canBackUpUsingCellular: Boolean, + backupsFrequency: MessageBackupsFrequency, + requestedDialog: RemoteBackupsSettingsState.Dialog, + requestedSnackbar: RemoteBackupsSettingsState.Snackbar, + contentCallbacks: ContentCallbacks +) { + val snackbarHostState = remember { + SnackbarHostState() + } + + Scaffolds.Settings( + title = "Signal Backups", + onNavigationClick = contentCallbacks::onNavigationClick, + navigationIconPainter = painterResource(id = R.drawable.symbol_arrow_left_24), + snackbarHost = { + Snackbars.Host(snackbarHostState = snackbarHostState) + } + ) { + LazyColumn( + modifier = Modifier + .padding(it) + ) { + item { + BackupTypeRow( + messageBackupsType = messageBackupsType, + onEnableBackupsClick = contentCallbacks::onEnableBackupsClick, + onChangeBackupsTypeClick = contentCallbacks::onChangeBackupsTypeClick + ) + } + + if (messageBackupsType == null) { + item { + Rows.TextRow( + text = "Payment history", + onClick = contentCallbacks::onViewPaymentHistory + ) + } + } else { + item { + Dividers.Default() + } + + item { + Texts.SectionHeader(text = "Backup Details") + } + + item { + LastBackupRow( + lastBackupTimestamp = lastBackupTimestamp, + onBackupNowClick = {} + ) + } + + item { + Rows.TextRow(text = { + Column { + Text( + text = "Backup size", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "2.3GB", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }) + } + + item { + Rows.TextRow( + text = { + Column { + Text( + text = "Backup frequency", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = getTextForFrequency(backupsFrequency = backupsFrequency), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + onClick = contentCallbacks::onChangeBackupFrequencyClick + ) + } + + item { + Rows.ToggleRow( + checked = canBackUpUsingCellular, + text = "Back up using cellular", + onCheckChanged = contentCallbacks::onBackUpUsingCellularClick + ) + } + + item { + Dividers.Default() + } + + item { + Rows.TextRow( + text = "Turn off and delete backup", + foregroundTint = MaterialTheme.colorScheme.error, + onClick = contentCallbacks::onTurnOffAndDeleteBackupsClick + ) + } + } + } + } + + when (requestedDialog) { + RemoteBackupsSettingsState.Dialog.NONE -> {} + RemoteBackupsSettingsState.Dialog.TURN_OFF_AND_DELETE_BACKUPS -> { + TurnOffAndDeleteBackupsDialog( + onConfirm = contentCallbacks::onTurnOffAndDeleteBackupsConfirm, + onDismiss = contentCallbacks::onDialogDismissed + ) + } + + RemoteBackupsSettingsState.Dialog.BACKUP_FREQUENCY -> { + BackupFrequencyDialog( + selected = backupsFrequency, + onSelected = contentCallbacks::onSelectBackupsFrequencyChange, + onDismiss = contentCallbacks::onDialogDismissed + ) + } + } + + LaunchedEffect(requestedSnackbar) { + when (requestedSnackbar) { + RemoteBackupsSettingsState.Snackbar.NONE -> { + snackbarHostState.currentSnackbarData?.dismiss() + } + + RemoteBackupsSettingsState.Snackbar.BACKUP_DELETED_AND_TURNED_OFF -> { + snackbarHostState.showSnackbar( + "Backup deleted and turned off" + ) + } + + RemoteBackupsSettingsState.Snackbar.BACKUP_TYPE_CHANGED_AND_SUBSCRIPTION_CANCELLED -> { + snackbarHostState.showSnackbar( + "Backup type changed and subscription cancelled" + ) + } + + RemoteBackupsSettingsState.Snackbar.SUBSCRIPTION_CANCELLED -> { + snackbarHostState.showSnackbar( + "Subscription cancelled" + ) + } + + RemoteBackupsSettingsState.Snackbar.DOWNLOAD_COMPLETE -> { + snackbarHostState.showSnackbar( + "Download complete" + ) + } + } + contentCallbacks.onSnackbarDismissed() + } +} + +@Composable +private fun BackupTypeRow( + messageBackupsType: MessageBackupsType?, + onEnableBackupsClick: () -> Unit, + onChangeBackupsTypeClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = messageBackupsType != null, onClick = onChangeBackupsTypeClick) + .padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter)) + .padding(top = 16.dp, bottom = 14.dp) + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = "Backup type", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + + if (messageBackupsType == null) { + Text( + text = "Backups disabled", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + val localResources = LocalContext.current.resources + val formattedCurrency = remember(messageBackupsType.pricePerMonth) { + FiatMoneyUtil.format(localResources, messageBackupsType.pricePerMonth, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal()) + } + + Text( + text = "${messageBackupsType.title} ยท $formattedCurrency/month" + ) + } + } + + if (messageBackupsType == null) { + Buttons.Small(onClick = onEnableBackupsClick) { + Text(text = "Enable backups") + } + } + } +} + +@Composable +private fun LastBackupRow( + lastBackupTimestamp: Long, + onBackupNowClick: () -> Unit +) { + Row( + modifier = Modifier + .padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter)) + .padding(top = 16.dp, bottom = 14.dp) + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = "Last backup", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + + 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) + } + + Text( + text = "$day at $time", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + Text( + text = "Never", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Buttons.Small(onClick = onBackupNowClick) { + Text(text = "Back up now") + } + } +} + +@Composable +private fun TurnOffAndDeleteBackupsDialog( + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + Dialogs.SimpleAlertDialog( + title = "Turn off and delete backups?", + body = "You will not be charged again. Your backup will be deleted and no new backups will be created.", + confirm = "Turn off and delete", + dismiss = stringResource(id = android.R.string.cancel), + confirmColor = MaterialTheme.colorScheme.error, + onConfirm = onConfirm, + onDismiss = onDismiss + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun BackupFrequencyDialog( + selected: MessageBackupsFrequency, + onSelected: (MessageBackupsFrequency) -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss + ) { + Column( + modifier = Modifier + .background( + color = AlertDialogDefaults.containerColor, + shape = AlertDialogDefaults.shape + ) + .fillMaxWidth() + ) { + Text( + text = "Backup frequency", + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(24.dp) + ) + + MessageBackupsFrequency.values().forEach { + Rows.RadioRow( + selected = selected == it, + text = getTextForFrequency(backupsFrequency = it), + label = when (it) { + MessageBackupsFrequency.NEVER -> "By tapping \"Back up now\"" + else -> null + }, + modifier = Modifier + .padding(end = 24.dp) + .clickable(onClick = { + onSelected(it) + onDismiss() + }) + ) + } + + Box( + contentAlignment = Alignment.CenterEnd, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 24.dp) + ) { + TextButton(onClick = onDismiss) { + Text(text = stringResource(id = android.R.string.cancel)) + } + } + } + } +} + +@Composable +private fun getTextForFrequency(backupsFrequency: MessageBackupsFrequency): String { + return when (backupsFrequency) { + MessageBackupsFrequency.DAILY -> "Daily" + MessageBackupsFrequency.WEEKLY -> "Weekly" + MessageBackupsFrequency.MONTHLY -> "Monthly" + MessageBackupsFrequency.NEVER -> "Manually back up" + } +} + +@SignalPreview +@Composable +private fun RemoteBackupsSettingsContentPreview() { + Previews.Preview { + RemoteBackupsSettingsContent( + messageBackupsType = null, + lastBackupTimestamp = -1, + canBackUpUsingCellular = false, + backupsFrequency = MessageBackupsFrequency.NEVER, + requestedDialog = RemoteBackupsSettingsState.Dialog.NONE, + requestedSnackbar = RemoteBackupsSettingsState.Snackbar.NONE, + contentCallbacks = object : ContentCallbacks {} + ) + } +} + +@SignalPreview +@Composable +private fun BackupTypeRowPreview() { + Previews.Preview { + BackupTypeRow( + messageBackupsType = MessageBackupsType( + title = "Text + all media", + pricePerMonth = FiatMoney(BigDecimal.valueOf(3L), Currency.getInstance(Locale.US)), + features = persistentListOf() + ), + onChangeBackupsTypeClick = {}, + onEnableBackupsClick = {} + ) + } +} + +@SignalPreview +@Composable +private fun LastBackupRowPreview() { + Previews.Preview { + LastBackupRow( + lastBackupTimestamp = -1, + onBackupNowClick = {} + ) + } +} + +@SignalPreview +@Composable +private fun TurnOffAndDeleteBackupsDialogPreview() { + Previews.Preview { + TurnOffAndDeleteBackupsDialog( + onConfirm = {}, + onDismiss = {} + ) + } +} + +@SignalPreview +@Composable +private fun BackupFrequencyDialogPreview() { + Previews.Preview { + BackupFrequencyDialog( + selected = MessageBackupsFrequency.DAILY, + onSelected = {}, + onDismiss = {} + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/backups/RemoteBackupsSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/backups/RemoteBackupsSettingsState.kt new file mode 100644 index 0000000000..5320318d41 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/backups/RemoteBackupsSettingsState.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.chats.backups + +import org.thoughtcrime.securesms.backup.v2.ui.MessageBackupsFrequency +import org.thoughtcrime.securesms.backup.v2.ui.MessageBackupsType + +data class RemoteBackupsSettingsState( + val messageBackupsType: MessageBackupsType? = null, + val canBackUpUsingCellular: Boolean = false, + val backupSize: Long = 0, + val backupsFrequency: MessageBackupsFrequency = MessageBackupsFrequency.DAILY, + val lastBackupTimestamp: Long = 0, + val dialog: Dialog = Dialog.NONE, + val snackbar: Snackbar = Snackbar.NONE +) { + enum class Dialog { + NONE, + TURN_OFF_AND_DELETE_BACKUPS, + BACKUP_FREQUENCY + } + + enum class Snackbar { + NONE, + BACKUP_DELETED_AND_TURNED_OFF, + BACKUP_TYPE_CHANGED_AND_SUBSCRIPTION_CANCELLED, + SUBSCRIPTION_CANCELLED, + DOWNLOAD_COMPLETE + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/backups/RemoteBackupsSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/backups/RemoteBackupsSettingsViewModel.kt new file mode 100644 index 0000000000..ed2cd30201 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/backups/RemoteBackupsSettingsViewModel.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.chats.backups + +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import org.thoughtcrime.securesms.backup.v2.ui.MessageBackupsFrequency + +/** + * ViewModel for state management of RemoteBackupsSettingsFragment + */ +class RemoteBackupsSettingsViewModel : ViewModel() { + private val internalState = mutableStateOf(RemoteBackupsSettingsState()) + + val state: State = internalState + + fun setCanBackUpUsingCellular(canBackUpUsingCellular: Boolean) { + // TODO [message-backups] -- Update via repository? + internalState.value = state.value.copy(canBackUpUsingCellular = canBackUpUsingCellular) + } + + fun setBackupsFrequency(backupsFrequency: MessageBackupsFrequency) { + // TODO [message-backups] -- Update via repository? + internalState.value = state.value.copy(backupsFrequency = backupsFrequency) + } + + fun requestDialog(dialog: RemoteBackupsSettingsState.Dialog) { + internalState.value = state.value.copy(dialog = dialog) + } + + fun requestSnackbar(snackbar: RemoteBackupsSettingsState.Snackbar) { + internalState.value = state.value.copy(snackbar = snackbar) + } + + fun turnOffAndDeleteBackups() { + // TODO [message-backups] -- Delete. + internalState.value = state.value.copy(snackbar = RemoteBackupsSettingsState.Snackbar.BACKUP_DELETED_AND_TURNED_OFF) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/CallInfoView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/CallInfoView.kt index ed924592c6..8b8dfd8f21 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/CallInfoView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/CallInfoView.kt @@ -34,12 +34,11 @@ import androidx.compose.runtime.rxjava3.subscribeAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView @@ -183,7 +182,7 @@ private fun CallInfo( item { Rows.TextRow( text = stringResource(id = R.string.CallLinkDetailsFragment__share_link), - icon = ImageVector.vectorResource(id = R.drawable.symbol_link_24), + icon = painterResource(id = R.drawable.symbol_link_24), iconModifier = Modifier .background( color = MaterialTheme.colorScheme.surfaceVariant, @@ -446,7 +445,7 @@ private fun CallParticipantRow( if (showIcons && showHandRaised) { Icon( - imageVector = ImageVector.vectorResource(id = R.drawable.symbol_raise_hand_24), + painter = painterResource(id = R.drawable.symbol_raise_hand_24), contentDescription = null, modifier = Modifier.align(Alignment.CenterVertically) ) @@ -454,7 +453,7 @@ private fun CallParticipantRow( if (showIcons && !isVideoEnabled) { Icon( - imageVector = ImageVector.vectorResource(id = R.drawable.symbol_video_slash_24), + painter = painterResource(id = R.drawable.symbol_video_slash_24), contentDescription = null, modifier = Modifier.align(Alignment.CenterVertically) ) @@ -466,7 +465,7 @@ private fun CallParticipantRow( } Icon( - imageVector = ImageVector.vectorResource(id = R.drawable.symbol_mic_slash_24), + painter = painterResource(id = R.drawable.symbol_mic_slash_24), contentDescription = null, modifier = Modifier.align(Alignment.CenterVertically) ) @@ -478,7 +477,7 @@ private fun CallParticipantRow( } Icon( - imageVector = ImageVector.vectorResource(id = R.drawable.symbol_minus_circle_24), + painter = painterResource(id = R.drawable.symbol_minus_circle_24), contentDescription = null, modifier = Modifier .clickable(onClick = onBlockClicked) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/requests/CallLinkIncomingRequestSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/requests/CallLinkIncomingRequestSheet.kt index 5911cacedb..10aea686a8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/requests/CallLinkIncomingRequestSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/requests/CallLinkIncomingRequestSheet.kt @@ -23,11 +23,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView @@ -165,7 +164,7 @@ private fun CallLinkIncomingRequestSheetContent( item { Rows.TextRow( text = stringResource(id = R.string.CallLinkIncomingRequestSheet__approve_entry), - icon = ImageVector.vectorResource(R.drawable.symbol_check_circle_24), + icon = painterResource(R.drawable.symbol_check_circle_24), onClick = onApproveEntry ) } @@ -173,7 +172,7 @@ private fun CallLinkIncomingRequestSheetContent( item { Rows.TextRow( text = stringResource(id = R.string.CallLinkIncomingRequestSheet__deny_entry), - icon = ImageVector.vectorResource(R.drawable.symbol_x_circle_24), + icon = painterResource(R.drawable.symbol_x_circle_24), onClick = onDenyEntry ) } @@ -219,7 +218,7 @@ private fun Title( style = MaterialTheme.typography.headlineMedium ) Icon( - imageVector = ImageVector.vectorResource(id = R.drawable.symbol_person_circle_24), + painter = painterResource(id = R.drawable.symbol_person_circle_24), contentDescription = null, modifier = Modifier .padding(start = 6.dp) diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt index 4e371b7cc4..57d0e91bc4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt @@ -25,6 +25,11 @@ internal class BackupValues(store: KeyValueStore) : SignalStoreValues(store) { private const val KEY_OPTIMIZE_STORAGE = "backup.optimizeStorage" + /** + * Specifies whether remote backups are enabled on this device. + */ + private const val KEY_BACKUPS_ENABLED = "backup.enabled" + private val cachedCdnCredentialsExpiresIn: Duration = 12.hours } @@ -40,6 +45,8 @@ internal class BackupValues(store: KeyValueStore) : SignalStoreValues(store) { var restoreState: RestoreState by enumValue(KEY_RESTORE_STATE, RestoreState.NONE, RestoreState.serializer) var optimizeStorage: Boolean by booleanValue(KEY_OPTIMIZE_STORAGE, false) + var areBackupsEnabled: Boolean by booleanValue(KEY_BACKUPS_ENABLED, false) + /** * Retrieves the stored credentials, mapped by the day they're valid. The day is represented as * the unix time (in seconds) of the start of the day. Wrapped in a [ArchiveServiceCredentials] diff --git a/app/src/main/res/navigation/app_settings.xml b/app/src/main/res/navigation/app_settings.xml index 211097fed9..38005862fb 100644 --- a/app/src/main/res/navigation/app_settings.xml +++ b/app/src/main/res/navigation/app_settings.xml @@ -300,6 +300,13 @@ app:exitAnim="@anim/fragment_open_exit" app:popEnterAnim="@anim/fragment_close_enter" app:popExitAnim="@anim/fragment_close_exit" /> + + + diff --git a/core-ui/src/main/java/org/signal/core/ui/Rows.kt b/core-ui/src/main/java/org/signal/core/ui/Rows.kt index 35fd45da4a..bd9368a092 100644 --- a/core-ui/src/main/java/org/signal/core/ui/Rows.kt +++ b/core-ui/src/main/java/org/signal/core/ui/Rows.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -22,8 +23,9 @@ import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -110,41 +112,53 @@ object Rows { text: String, modifier: Modifier = Modifier, iconModifier: Modifier = Modifier, - icon: ImageVector? = null, + icon: Painter? = null, foregroundTint: Color = MaterialTheme.colorScheme.onSurface, onClick: (() -> Unit)? = null ) { - if (icon != null) { - Row( - modifier = modifier - .fillMaxWidth() - .clickable(enabled = onClick != null, onClick = onClick ?: {}) - .padding(defaultPadding()) - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = foregroundTint, - modifier = iconModifier - ) - - Spacer(modifier = Modifier.width(24.dp)) - + TextRow( + text = { Text( text = text, - modifier = Modifier.weight(1f).align(CenterVertically), - color = foregroundTint + color = foregroundTint, + modifier = Modifier.align(CenterVertically) ) + }, + icon = if (icon != null) { + { + Icon( + painter = icon, + contentDescription = null, + tint = foregroundTint, + modifier = iconModifier + ) + } + } else { + null + }, + modifier = modifier, + onClick = onClick + ) + } + + @Composable + fun TextRow( + text: @Composable RowScope.() -> Unit, + modifier: Modifier = Modifier, + icon: (@Composable RowScope.() -> Unit)? = null, + onClick: (() -> Unit)? = null + ) { + Row( + modifier = modifier + .fillMaxWidth() + .clickable(enabled = onClick != null, onClick = onClick ?: {}) + .padding(defaultPadding()) + ) { + if (icon != null) { + icon() + Spacer(modifier = Modifier.width(24.dp)) } - } else { - Text( - text = text, - color = foregroundTint, - modifier = modifier - .fillMaxWidth() - .clickable(enabled = onClick != null, onClick = onClick ?: {}) - .padding(defaultPadding()) - ) + text() } } @@ -190,11 +204,13 @@ private fun ToggleRowPreview() { } } -@Preview +@SignalPreview @Composable private fun TextRowPreview() { - SignalTheme(isDarkMode = false) { - Rows.TextRow(text = "TextRow") - Rows.TextRow(text = "TextRow") + Previews.Preview { + Rows.TextRow( + text = "TextRow", + icon = painterResource(id = android.R.drawable.ic_menu_camera) + ) } } diff --git a/core-ui/src/main/java/org/signal/core/ui/SignalPreview.kt b/core-ui/src/main/java/org/signal/core/ui/SignalPreview.kt new file mode 100644 index 0000000000..ec457432cd --- /dev/null +++ b/core-ui/src/main/java/org/signal/core/ui/SignalPreview.kt @@ -0,0 +1,17 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.core.ui + +import android.content.res.Configuration +import androidx.compose.ui.tooling.preview.Preview + +/** + * Our very own preview that will generate light and dark previews for + * composables + */ +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +annotation class SignalPreview()