diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/BaseSettingsAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/components/settings/BaseSettingsAdapter.java index 5214471cae..23eeaaaabb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/BaseSettingsAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/BaseSettingsAdapter.java @@ -20,9 +20,4 @@ public class BaseSettingsAdapter extends MappingAdapter { registerFactory(SingleSelectSetting.Item.class, new LayoutFactory<>(v -> new SingleSelectSetting.ViewHolder(v, selectionChangedListener), R.layout.single_select_item)); } - - public void configureCustomizableSingleSelect(@NonNull CustomizableSingleSelectSetting.CustomizableSingleSelectionListener selectionListener) { - registerFactory(CustomizableSingleSelectSetting.Item.class, - new LayoutFactory<>(v -> new CustomizableSingleSelectSetting.ViewHolder(v, selectionListener), R.layout.customizable_single_select_item)); - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/BaseSettingsFragment.java b/app/src/main/java/org/thoughtcrime/securesms/components/settings/BaseSettingsFragment.java deleted file mode 100644 index d5841c2a0e..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/BaseSettingsFragment.java +++ /dev/null @@ -1,93 +0,0 @@ -package org.thoughtcrime.securesms.components.settings; - -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentActivity; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.util.adapter.mapping.MappingModelList; - -import java.io.Serializable; -import java.util.Objects; - -/** - * A simple settings screen that takes its configuration via {@link Configuration}. - */ -public class BaseSettingsFragment extends Fragment { - - private static final String CONFIGURATION_ARGUMENT = "current_selection"; - - private RecyclerView recycler; - - public static @NonNull BaseSettingsFragment create(@NonNull Configuration configuration) { - BaseSettingsFragment fragment = new BaseSettingsFragment(); - - Bundle arguments = new Bundle(); - arguments.putSerializable(CONFIGURATION_ARGUMENT, configuration); - fragment.setArguments(arguments); - - return fragment; - } - - @Override - public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - View view = inflater.inflate(R.layout.base_settings_fragment, container, false); - - recycler = view.findViewById(R.id.base_settings_list); - recycler.setItemAnimator(null); - - return view; - } - - @Override - public void onActivityCreated(@Nullable Bundle savedInstanceState) { - super.onActivityCreated(savedInstanceState); - - BaseSettingsAdapter adapter = new BaseSettingsAdapter(); - - recycler.setLayoutManager(new LinearLayoutManager(requireContext())); - recycler.setAdapter(adapter); - - Configuration configuration = (Configuration) Objects.requireNonNull(requireArguments().getSerializable(CONFIGURATION_ARGUMENT)); - configuration.configure(requireActivity(), adapter); - configuration.setArguments(getArguments()); - configuration.configureAdapter(adapter); - - adapter.submitList(configuration.getSettings()); - } - - /** - * A configuration for a settings screen. Utilizes serializable to hide - * reflection of instantiating from a fragment argument. - */ - public static abstract class Configuration implements Serializable { - protected transient FragmentActivity activity; - protected transient BaseSettingsAdapter adapter; - - public void configure(@NonNull FragmentActivity activity, @NonNull BaseSettingsAdapter adapter) { - this.activity = activity; - this.adapter = adapter; - } - - /** - * Retrieve any runtime information from the fragment's arguments. - */ - public void setArguments(@Nullable Bundle arguments) {} - - protected void updateSettingsList() { - adapter.submitList(getSettings()); - } - - public abstract void configureAdapter(@NonNull BaseSettingsAdapter adapter); - - public abstract @NonNull MappingModelList getSettings(); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/CustomizableSingleSelectSetting.java b/app/src/main/java/org/thoughtcrime/securesms/components/settings/CustomizableSingleSelectSetting.java deleted file mode 100644 index 33aec570a0..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/CustomizableSingleSelectSetting.java +++ /dev/null @@ -1,72 +0,0 @@ -package org.thoughtcrime.securesms.components.settings; - -import android.view.View; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.constraintlayout.widget.Group; - -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel; -import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder; - -import java.util.Objects; - -/** - * Adds ability to customize a value for a single select (radio) setting. - */ -public class CustomizableSingleSelectSetting { - - public interface CustomizableSingleSelectionListener extends SingleSelectSetting.SingleSelectSelectionChangedListener { - void onCustomizeClicked(@Nullable Item item); - } - - public static class ViewHolder extends MappingViewHolder { - private final View customize; - private final SingleSelectSetting.ViewHolder delegate; - private final Group customizeGroup; - private final CustomizableSingleSelectionListener selectionListener; - - public ViewHolder(@NonNull View itemView, @NonNull CustomizableSingleSelectionListener selectionListener) { - super(itemView); - this.selectionListener = selectionListener; - - customize = findViewById(R.id.customizable_single_select_customize); - customizeGroup = findViewById(R.id.customizable_single_select_customize_group); - - delegate = new SingleSelectSetting.ViewHolder(itemView, selectionListener); - } - - @Override - public void bind(@NonNull Item model) { - delegate.bind(model.singleSelectItem); - customizeGroup.setVisibility(model.singleSelectItem.isSelected() ? View.VISIBLE : View.GONE); - customize.setOnClickListener(v -> selectionListener.onCustomizeClicked(model)); - } - } - - public static class Item implements MappingModel { - private final SingleSelectSetting.Item singleSelectItem; - private final Object customValue; - - public Item(@NonNull T item, @Nullable String text, boolean isSelected, @Nullable Object customValue, @Nullable String summaryText) { - this.customValue = customValue; - - singleSelectItem = new SingleSelectSetting.Item(item, text, summaryText, isSelected); - } - - public @Nullable Object getCustomValue() { - return customValue; - } - - @Override - public boolean areItemsTheSame(@NonNull Item newItem) { - return singleSelectItem.areItemsTheSame(newItem.singleSelectItem); - } - - @Override - public boolean areContentsTheSame(@NonNull Item newItem) { - return singleSelectItem.areContentsTheSame(newItem.singleSelectItem) && Objects.equals(customValue, newItem.customValue); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/storage/ManageStorageSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/storage/ManageStorageSettingsFragment.kt new file mode 100644 index 0000000000..87552a8f2c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/storage/ManageStorageSettingsFragment.kt @@ -0,0 +1,523 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.storage + +import android.view.LayoutInflater +import android.view.View +import android.widget.TextView +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.integerArrayResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.window.DialogProperties +import androidx.core.content.ContextCompat +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.dialog +import androidx.navigation.compose.rememberNavController +import androidx.navigation.fragment.findNavController +import androidx.navigation.navArgument +import org.signal.core.ui.Animations +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.Rows.TextAndLabel +import org.signal.core.ui.Scaffolds +import org.signal.core.ui.SignalPreview +import org.signal.core.ui.Texts +import org.signal.core.ui.theme.SignalTheme +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.compose.ComposeFragment +import org.thoughtcrime.securesms.database.MediaTable +import org.thoughtcrime.securesms.keyvalue.KeepMessagesDuration +import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity +import org.thoughtcrime.securesms.preferences.widgets.StorageGraphView +import org.thoughtcrime.securesms.util.Util +import org.thoughtcrime.securesms.util.viewModel +import java.text.NumberFormat + +/** + * Manage settings related to on-device storage including viewing usage and auto-delete settings. + */ +class ManageStorageSettingsFragment : ComposeFragment() { + + private val viewModel by viewModel { ManageStorageSettingsViewModel() } + + @Composable + override fun FragmentContent() { + val state by viewModel.state.collectAsState() + + val navController = rememberNavController() + + SignalTheme { + NavHost( + navController = navController, + startDestination = "manage", + enterTransition = { Animations.navHostSlideInTransition { it } }, + exitTransition = { Animations.navHostSlideOutTransition { -it } }, + popEnterTransition = { Animations.navHostSlideInTransition { -it } }, + popExitTransition = { Animations.navHostSlideOutTransition { it } } + ) { + composable("manage") { + ManageStorageSettingsScreen( + state = state, + onNavigationClick = { findNavController().popBackStack() }, + onReviewStorage = { startActivity(MediaOverviewActivity.forAll(requireContext())) }, + onSetKeepMessages = { navController.navigate("set-keep-messages") }, + onSetChatLengthLimit = { navController.navigate("set-chat-length-limit") }, + onDeleteChatHistory = { navController.navigate("confirm-delete-chat-history") } + ) + } + + composable("set-keep-messages") { + SetKeepMessagesScreen( + selection = state.keepMessagesDuration, + onNavigationClick = { navController.popBackStack() }, + onSelectionChanged = { newDuration -> + if (viewModel.showConfirmKeepDurationChange(newDuration)) { + navController.navigate("confirm-set-keep-messages/${newDuration.id}") + } else { + viewModel.setKeepMessagesDuration(newDuration) + } + } + ) + } + + composable("set-chat-length-limit") { + SetChatLengthLimitScreen( + currentLimit = state.lengthLimit, + onNavigationClick = { navController.popBackStack() }, + onOptionSelected = { newLengthLimit -> + if (viewModel.showConfirmSetChatLengthLimit(newLengthLimit)) { + navController.navigate("confirm-set-length-limit/$newLengthLimit") + } else { + viewModel.setChatLengthLimit(newLengthLimit) + } + }, + onCustomSelected = { navController.navigate("custom-set-length-limit") } + ) + } + + dialog("confirm-delete-chat-history") { + Dialogs.SimpleAlertDialog( + title = stringResource(id = R.string.preferences_storage__delete_message_history), + body = stringResource(id = R.string.preferences_storage__this_will_delete_all_message_history_and_media_from_your_device), + confirm = stringResource(id = R.string.delete), + confirmColor = MaterialTheme.colorScheme.error, + dismiss = stringResource(id = android.R.string.cancel), + onConfirm = { navController.navigate("double-confirm-delete-chat-history") }, + onDismiss = { navController.popBackStack() } + ) + } + + dialog("double-confirm-delete-chat-history", dialogProperties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = true)) { + Dialogs.SimpleAlertDialog( + title = stringResource(id = R.string.preferences_storage__are_you_sure_you_want_to_delete_all_message_history), + body = stringResource(id = R.string.preferences_storage__all_message_history_will_be_permanently_removed_this_action_cannot_be_undone), + confirm = stringResource(id = R.string.preferences_storage__delete_all_now), + confirmColor = MaterialTheme.colorScheme.error, + dismiss = stringResource(id = android.R.string.cancel), + onConfirm = { viewModel.deleteChatHistory() }, + onDismiss = { navController.popBackStack() } + ) + } + + dialog( + route = "confirm-set-keep-messages/{keepMessagesId}", + arguments = listOf(navArgument("keepMessagesId") { type = NavType.IntType }) + ) { + val newDuration = KeepMessagesDuration.fromId(it.arguments!!.getInt("keepMessagesId")) + + Dialogs.SimpleAlertDialog( + title = stringResource(id = R.string.preferences_storage__delete_older_messages), + body = stringResource(id = R.string.preferences_storage__this_will_permanently_delete_all_message_history_and_media, stringResource(id = newDuration.stringResource)), + confirm = stringResource(id = R.string.delete), + dismiss = stringResource(id = android.R.string.cancel), + onConfirm = { viewModel.setKeepMessagesDuration(newDuration) }, + onDismiss = { navController.popBackStack() } + ) + } + + dialog( + route = "confirm-set-length-limit/{lengthLimit}", + arguments = listOf(navArgument("lengthLimit") { type = NavType.IntType }) + ) { + val newLengthLimit = it.arguments!!.getInt("lengthLimit") + + Dialogs.SimpleAlertDialog( + title = stringResource(id = R.string.preferences_storage__delete_older_messages), + body = pluralStringResource( + id = R.plurals.preferences_storage__this_will_permanently_trim_all_conversations_to_the_d_most_recent_messages, + count = newLengthLimit, + NumberFormat.getInstance().format(newLengthLimit) + ), + confirm = stringResource(id = R.string.delete), + dismiss = stringResource(id = android.R.string.cancel), + onConfirm = { viewModel.setChatLengthLimit(newLengthLimit) }, + onDismiss = { navController.popBackStack() } + ) + } + + dialog( + route = "custom-set-length-limit" + ) { + SetCustomLengthLimitDialog( + currentLimit = if (state.lengthLimit != ManageStorageSettingsViewModel.ManageStorageState.NO_LIMIT) state.lengthLimit else null, + onCustomLimitSet = { newLengthLimit -> + if (viewModel.showConfirmSetChatLengthLimit(newLengthLimit)) { + navController.navigate("confirm-set-length-limit/$newLengthLimit") + } else { + viewModel.setChatLengthLimit(newLengthLimit) + } + }, + onDismiss = { navController.popBackStack() } + ) + } + } + } + } + + override fun onResume() { + super.onResume() + viewModel.refresh() + } +} + +@Composable +private fun ManageStorageSettingsScreen( + state: ManageStorageSettingsViewModel.ManageStorageState, + onNavigationClick: () -> Unit = {}, + onReviewStorage: () -> Unit = {}, + onSetKeepMessages: () -> Unit = {}, + onSetChatLengthLimit: () -> Unit = {}, + onDeleteChatHistory: () -> Unit = {} +) { + Scaffolds.Settings( + title = stringResource(id = R.string.preferences__storage), + onNavigationClick = onNavigationClick, + navigationIconPainter = painterResource(id = R.drawable.ic_arrow_left_24) + ) { contentPadding -> + Column( + modifier = Modifier + .padding(contentPadding) + .verticalScroll(rememberScrollState()) + ) { + Texts.SectionHeader(text = stringResource(id = R.string.preferences_storage__storage_usage)) + + StorageOverview(state.breakdown, onReviewStorage) + + Dividers.Default() + + Texts.SectionHeader(text = stringResource(id = R.string.ManageStorageSettingsFragment_chat_limit)) + + Rows.TextRow( + text = stringResource(id = R.string.preferences__keep_messages), + label = stringResource(id = state.keepMessagesDuration.stringResource), + onClick = onSetKeepMessages + ) + + Rows.TextRow( + text = stringResource(id = R.string.preferences__conversation_length_limit), + label = if (state.lengthLimit != ManageStorageSettingsViewModel.ManageStorageState.NO_LIMIT) { + pluralStringResource( + id = R.plurals.preferences_storage__s_messages_plural, + count = state.lengthLimit, + NumberFormat.getInstance().format(state.lengthLimit) + ) + } else { + stringResource(id = R.string.preferences_storage__none) + }, + onClick = onSetChatLengthLimit + ) + + Dividers.Default() + + Rows.TextRow( + text = stringResource(id = R.string.ManageStorageSettingsFragment_delete_message_history), + onClick = onDeleteChatHistory + ) + } + } +} + +@Composable +private fun StorageOverview( + breakdown: MediaTable.StorageBreakdown?, + onReviewStorage: () -> Unit +) { + AndroidView( + factory = { + LayoutInflater.from(it).inflate(R.layout.preference_storage_category, null) + }, + modifier = Modifier.fillMaxWidth() + ) { + if (breakdown != null) { + val breakdownEntries = StorageGraphView.StorageBreakdown( + listOf( + StorageGraphView.Entry(ContextCompat.getColor(it.context, R.color.storage_color_photos), breakdown.photoSize), + StorageGraphView.Entry(ContextCompat.getColor(it.context, R.color.storage_color_videos), breakdown.videoSize), + StorageGraphView.Entry(ContextCompat.getColor(it.context, R.color.storage_color_files), breakdown.documentSize), + StorageGraphView.Entry(ContextCompat.getColor(it.context, R.color.storage_color_audio), breakdown.audioSize) + ) + ) + + it.findViewById(R.id.storageGraphView).setStorageBreakdown(breakdownEntries) + it.findViewById(R.id.total_size).text = Util.getPrettyFileSize(breakdownEntries.totalSize) + } + + it.findViewById(R.id.free_up_space).setOnClickListener { + onReviewStorage() + } + } +} + +@Composable +private fun SetKeepMessagesScreen( + selection: KeepMessagesDuration, + onNavigationClick: () -> Unit = {}, + onSelectionChanged: (KeepMessagesDuration) -> Unit = {} +) { + Scaffolds.Settings( + title = stringResource(id = R.string.preferences__keep_messages), + onNavigationClick = onNavigationClick, + navigationIconPainter = painterResource(id = R.drawable.ic_arrow_left_24) + ) { contentPadding -> + Column( + modifier = Modifier + .padding(contentPadding) + .verticalScroll(rememberScrollState()) + ) { + KeepMessagesDuration + .values() + .forEach { + Rows.RadioRow( + text = stringResource(id = it.stringResource), + selected = it == selection, + modifier = Modifier.clickable { onSelectionChanged(it) } + ) + } + + Rows.TextRow( + text = { + Text( + text = stringResource(id = R.string.ManageStorageSettingsFragment_keep_messages_duration_warning), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + ) + } + } +} + +@Composable +private fun SetChatLengthLimitScreen( + currentLimit: Int, + onNavigationClick: () -> Unit = {}, + onOptionSelected: (Int) -> Unit = {}, + onCustomSelected: (Int) -> Unit = {} +) { + Scaffolds.Settings( + title = stringResource(id = R.string.preferences__conversation_length_limit), + onNavigationClick = onNavigationClick, + navigationIconPainter = painterResource(id = R.drawable.ic_arrow_left_24) + ) { contentPadding -> + Column( + modifier = Modifier + .padding(contentPadding) + .verticalScroll(rememberScrollState()) + ) { + val options = integerArrayResource(id = R.array.conversation_length_limit) + var customSelected = true + + for (option in options) { + val isSelected = option == currentLimit + + Rows.RadioRow( + selected = isSelected, + text = if (option == 0) { + stringResource(id = R.string.preferences_storage__none) + } else { + pluralStringResource(id = R.plurals.preferences_storage__s_messages_plural, count = option, NumberFormat.getInstance().format(option.toLong())) + }, + modifier = Modifier.clickable { onOptionSelected(option) } + ) + + if (isSelected) { + customSelected = false + } + } + + Rows.RadioRow( + content = { + TextAndLabel( + text = stringResource(id = R.string.preferences_storage__custom), + label = if (customSelected) { + pluralStringResource(id = R.plurals.preferences_storage__s_messages_plural, count = currentLimit, NumberFormat.getInstance().format(currentLimit)) + } else { + null + } + ) + + if (customSelected) { + Dividers.Vertical( + modifier = Modifier + .padding(horizontal = 8.dp) + .height(48.dp) + ) + + Icon( + painter = painterResource(id = R.drawable.symbol_settings_android_24), + contentDescription = null, + modifier = Modifier + .clickable { onCustomSelected(currentLimit) } + .padding(12.dp) + ) + } + }, + selected = customSelected, + modifier = Modifier.clickable { onCustomSelected(currentLimit) } + ) + + Rows.TextRow( + text = { + Text( + text = stringResource(id = R.string.ManageStorageSettingsFragment_chat_length_limit_warning), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + ) + } + } +} + +@Composable +private fun SetCustomLengthLimitDialog( + currentLimit: Int? = 1000, + onCustomLimitSet: (Int) -> Unit = {}, + onDismiss: () -> Unit = {} +) { + var lengthLimit by remember { + mutableStateOf( + TextFieldValue( + text = currentLimit?.toString() ?: "", + selection = TextRange(currentLimit.toString().length) + ) + ) + } + + val focusRequester = remember { FocusRequester() } + + androidx.compose.material3.AlertDialog( + onDismissRequest = onDismiss, + title = { Text(text = stringResource(id = R.string.preferences__conversation_length_limit)) }, + text = { + TextField( + value = lengthLimit, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + onValueChange = { value -> + val cleaned = value.text.replace("\\D".toRegex(), "") + lengthLimit = if (cleaned == value.text) { + value + } else { + value.copy(text = cleaned) + } + }, + modifier = Modifier.focusRequester(focusRequester) + ) + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + }, + confirmButton = { + TextButton( + enabled = lengthLimit.text.toIntOrNull() != null, + onClick = { + onDismiss() + onCustomLimitSet(lengthLimit.text.toInt()) + } + ) { + Text(text = stringResource(id = android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(text = stringResource(id = android.R.string.cancel)) + } + } + ) +} + +@SignalPreview +@Composable +private fun ManageStorageSettingsScreenPreview() { + Previews.Preview { + ManageStorageSettingsScreen( + state = ManageStorageSettingsViewModel.ManageStorageState( + keepMessagesDuration = KeepMessagesDuration.FOREVER, + lengthLimit = ManageStorageSettingsViewModel.ManageStorageState.NO_LIMIT + ) + ) + } +} + +@SignalPreview +@Composable +private fun SetKeepMessagesScreenPreview() { + Previews.Preview { + SetKeepMessagesScreen(selection = KeepMessagesDuration.FOREVER) + } +} + +@SignalPreview +@Composable +private fun SetChatLengthLimitScreenPreview() { + Previews.Preview { + SetChatLengthLimitScreen( + currentLimit = 1000 + ) + } +} + +@SignalPreview +@Composable +private fun SetCustomLengthLimitDialogPreview() { + Previews.Preview { + SetCustomLengthLimitDialog(currentLimit = 123) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/storage/ManageStorageSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/storage/ManageStorageSettingsViewModel.kt new file mode 100644 index 0000000000..e5f16f296b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/storage/ManageStorageSettingsViewModel.kt @@ -0,0 +1,99 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.storage + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.signal.core.util.concurrent.SignalExecutors +import org.thoughtcrime.securesms.database.MediaTable +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.SignalDatabase.Companion.media +import org.thoughtcrime.securesms.database.ThreadTable +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.keyvalue.KeepMessagesDuration +import org.thoughtcrime.securesms.keyvalue.SignalStore + +class ManageStorageSettingsViewModel : ViewModel() { + + private val store = MutableStateFlow( + ManageStorageState( + keepMessagesDuration = SignalStore.settings().keepMessagesDuration, + lengthLimit = if (SignalStore.settings().isTrimByLengthEnabled) SignalStore.settings().threadTrimLength else ManageStorageState.NO_LIMIT + ) + ) + val state = store.asStateFlow() + + fun refresh() { + viewModelScope.launch { + val breakdown: MediaTable.StorageBreakdown = media.getStorageBreakdown() + store.update { it.copy(breakdown = breakdown) } + } + } + + fun deleteChatHistory() { + viewModelScope.launch { + SignalDatabase.threads.deleteAllConversations() + ApplicationDependencies.getMessageNotifier().updateNotification(ApplicationDependencies.getApplication()) + } + } + + fun setKeepMessagesDuration(newDuration: KeepMessagesDuration) { + SignalStore.settings().setKeepMessagesForDuration(newDuration) + ApplicationDependencies.getTrimThreadsByDateManager().scheduleIfNecessary() + + store.update { it.copy(keepMessagesDuration = newDuration) } + } + + fun showConfirmKeepDurationChange(newDuration: KeepMessagesDuration): Boolean { + return newDuration.ordinal > state.value.keepMessagesDuration.ordinal + } + + fun setChatLengthLimit(newLimit: Int) { + val restrictingChange = isRestrictingLengthLimitChange(newLimit) + + SignalStore.settings().setThreadTrimByLengthEnabled(newLimit != ManageStorageState.NO_LIMIT) + SignalStore.settings().threadTrimLength = newLimit + store.update { it.copy(lengthLimit = newLimit) } + + if (SignalStore.settings().isTrimByLengthEnabled && restrictingChange) { + SignalExecutors.BOUNDED.execute { + val keepMessagesDuration = SignalStore.settings().keepMessagesDuration + + val trimBeforeDate = if (keepMessagesDuration != KeepMessagesDuration.FOREVER) { + System.currentTimeMillis() - keepMessagesDuration.duration + } else { + ThreadTable.NO_TRIM_BEFORE_DATE_SET + } + + SignalDatabase.threads.trimAllThreads(newLimit, trimBeforeDate) + } + } + } + + fun showConfirmSetChatLengthLimit(newLimit: Int): Boolean { + return isRestrictingLengthLimitChange(newLimit) + } + + private fun isRestrictingLengthLimitChange(newLimit: Int): Boolean { + return state.value.lengthLimit == ManageStorageState.NO_LIMIT || (newLimit != ManageStorageState.NO_LIMIT && newLimit < state.value.lengthLimit) + } + + @Immutable + data class ManageStorageState( + val keepMessagesDuration: KeepMessagesDuration = KeepMessagesDuration.FOREVER, + val lengthLimit: Int = NO_LIMIT, + val breakdown: MediaTable.StorageBreakdown? = null + ) { + companion object { + const val NO_LIMIT = 0 + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/wrapped/WrappedStoragePreferenceFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/wrapped/WrappedStoragePreferenceFragment.kt deleted file mode 100644 index 26749ede65..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/wrapped/WrappedStoragePreferenceFragment.kt +++ /dev/null @@ -1,10 +0,0 @@ -package org.thoughtcrime.securesms.components.settings.app.wrapped - -import androidx.fragment.app.Fragment -import org.thoughtcrime.securesms.preferences.StoragePreferenceFragment - -class WrappedStoragePreferenceFragment : SettingsWrapperFragment() { - override fun getFragment(): Fragment { - return StoragePreferenceFragment() - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MediaTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MediaTable.kt index f8fea47e96..6855225b4c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MediaTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MediaTable.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.database import android.annotation.SuppressLint import android.content.Context import android.database.Cursor +import androidx.compose.runtime.Immutable import org.signal.core.util.requireInt import org.signal.core.util.requireLong import org.signal.core.util.requireNonNullString @@ -302,6 +303,7 @@ class MediaTable internal constructor(context: Context?, databaseHelper: SignalD } } + @Immutable data class StorageBreakdown( val photoSize: Long, val videoSize: Long, diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/KeepMessagesDuration.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/KeepMessagesDuration.java index 6577c5dad1..7404abb66a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/KeepMessagesDuration.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/KeepMessagesDuration.java @@ -35,7 +35,7 @@ public enum KeepMessagesDuration { return duration; } - static @NonNull KeepMessagesDuration fromId(int id) { + public static @NonNull KeepMessagesDuration fromId(int id) { return values()[id]; } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/ApplicationPreferencesViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/ApplicationPreferencesViewModel.java deleted file mode 100644 index 233d37e841..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/ApplicationPreferencesViewModel.java +++ /dev/null @@ -1,47 +0,0 @@ -package org.thoughtcrime.securesms.preferences; - -import android.content.Context; - -import androidx.annotation.NonNull; -import androidx.core.content.ContextCompat; -import androidx.fragment.app.FragmentActivity; -import androidx.lifecycle.LiveData; -import androidx.lifecycle.MutableLiveData; -import androidx.lifecycle.ViewModel; -import androidx.lifecycle.ViewModelProvider; - -import org.signal.core.util.concurrent.SignalExecutors; -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.database.MediaTable; -import org.thoughtcrime.securesms.database.SignalDatabase; -import org.thoughtcrime.securesms.preferences.widgets.StorageGraphView; - -import java.util.Arrays; - -public class ApplicationPreferencesViewModel extends ViewModel { - - private final MutableLiveData storageBreakdown = new MutableLiveData<>(); - - LiveData getStorageBreakdown() { - return storageBreakdown; - } - - static ApplicationPreferencesViewModel getApplicationPreferencesViewModel(@NonNull FragmentActivity activity) { - return new ViewModelProvider(activity).get(ApplicationPreferencesViewModel.class); - } - - void refreshStorageBreakdown(@NonNull Context context) { - SignalExecutors.BOUNDED.execute(() -> { - MediaTable.StorageBreakdown breakdown = SignalDatabase.media().getStorageBreakdown(); - - StorageGraphView.StorageBreakdown latestStorageBreakdown = new StorageGraphView.StorageBreakdown(Arrays.asList( - new StorageGraphView.Entry(ContextCompat.getColor(context, R.color.storage_color_photos), breakdown.getPhotoSize()), - new StorageGraphView.Entry(ContextCompat.getColor(context, R.color.storage_color_videos), breakdown.getVideoSize()), - new StorageGraphView.Entry(ContextCompat.getColor(context, R.color.storage_color_files), breakdown.getDocumentSize()), - new StorageGraphView.Entry(ContextCompat.getColor(context, R.color.storage_color_audio), breakdown.getAudioSize()) - )); - - storageBreakdown.postValue(latestStorageBreakdown); - }); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/StoragePreferenceFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/StoragePreferenceFragment.java deleted file mode 100644 index 2dd192f985..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/StoragePreferenceFragment.java +++ /dev/null @@ -1,312 +0,0 @@ -package org.thoughtcrime.securesms.preferences; - -import android.annotation.SuppressLint; -import android.os.Bundle; -import android.text.Editable; -import android.text.TextUtils; -import android.text.TextWatcher; -import android.view.LayoutInflater; -import android.view.View; -import android.widget.EditText; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.StringRes; -import androidx.appcompat.app.AlertDialog; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentActivity; -import androidx.preference.Preference; - -import com.annimon.stream.Stream; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; - -import org.signal.core.util.concurrent.SignalExecutors; -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.components.settings.BaseSettingsAdapter; -import org.thoughtcrime.securesms.components.settings.BaseSettingsFragment; -import org.thoughtcrime.securesms.components.settings.CustomizableSingleSelectSetting; -import org.thoughtcrime.securesms.components.settings.SingleSelectSetting; -import org.thoughtcrime.securesms.components.settings.app.wrapped.SettingsWrapperFragment; -import org.thoughtcrime.securesms.database.SignalDatabase; -import org.thoughtcrime.securesms.database.ThreadTable; -import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; -import org.thoughtcrime.securesms.keyvalue.KeepMessagesDuration; -import org.thoughtcrime.securesms.keyvalue.SettingsValues; -import org.thoughtcrime.securesms.keyvalue.SignalStore; -import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity; -import org.thoughtcrime.securesms.permissions.Permissions; -import org.thoughtcrime.securesms.preferences.widgets.StoragePreferenceCategory; -import org.signal.core.util.StringUtil; -import org.thoughtcrime.securesms.util.adapter.mapping.MappingModelList; - -import java.text.NumberFormat; - -public class StoragePreferenceFragment extends ListSummaryPreferenceFragment { - - private Preference keepMessages; - private Preference trimLength; - - @Override - public void onCreate(@Nullable Bundle paramBundle) { - super.onCreate(paramBundle); - - findPreference("pref_storage_clear_message_history") - .setOnPreferenceClickListener(new ClearMessageHistoryClickListener()); - - trimLength = findPreference(SettingsValues.THREAD_TRIM_LENGTH); - trimLength.setOnPreferenceClickListener(p -> { - updateToolbarTitle(R.string.preferences__conversation_length_limit); - pushFragment(BaseSettingsFragment.create(new ConversationLengthLimitConfiguration())); - return true; - }); - - keepMessages = findPreference(SettingsValues.KEEP_MESSAGES_DURATION); - keepMessages.setOnPreferenceClickListener(p -> { - updateToolbarTitle(R.string.preferences__keep_messages); - pushFragment(BaseSettingsFragment.create(new KeepMessagesConfiguration())); - return true; - }); - - StoragePreferenceCategory storageCategory = (StoragePreferenceCategory) findPreference("pref_storage_category"); - FragmentActivity activity = requireActivity(); - ApplicationPreferencesViewModel viewModel = ApplicationPreferencesViewModel.getApplicationPreferencesViewModel(activity); - - storageCategory.setOnFreeUpSpace(() -> activity.startActivity(MediaOverviewActivity.forAll(activity))); - - viewModel.getStorageBreakdown().observe(activity, storageCategory::setStorage); - } - - @Override - public void onCreatePreferences(@Nullable Bundle savedInstanceState, @Nullable String rootKey) { - addPreferencesFromResource(R.xml.preferences_storage); - } - - @Override - public void onResume() { - super.onResume(); - updateToolbarTitle(R.string.preferences__storage); - - FragmentActivity activity = requireActivity(); - ApplicationPreferencesViewModel viewModel = ApplicationPreferencesViewModel.getApplicationPreferencesViewModel(activity); - - viewModel.refreshStorageBreakdown(activity.getApplicationContext()); - - keepMessages.setSummary(SignalStore.settings().getKeepMessagesDuration().getStringResource()); - - trimLength.setSummary(SignalStore.settings().isTrimByLengthEnabled() ? getResources().getQuantityString(R.plurals.preferences_storage__s_messages_plural, SignalStore.settings().getThreadTrimLength(), NumberFormat.getInstance().format(SignalStore.settings().getThreadTrimLength())) - : getString(R.string.preferences_storage__none)); - } - - @Override - public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { - Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); - } - - private void updateToolbarTitle(@StringRes int title) { - if (getParentFragment() instanceof SettingsWrapperFragment) { - ((SettingsWrapperFragment) getParentFragment()).setTitle(title); - } - } - - private void pushFragment(@NonNull Fragment fragment) { - getParentFragmentManager().beginTransaction() - .replace(R.id.wrapped_fragment, fragment) - .addToBackStack(null) - .commit(); - } - - private class ClearMessageHistoryClickListener implements Preference.OnPreferenceClickListener { - @Override - public boolean onPreferenceClick(@NonNull Preference preference) { - new MaterialAlertDialogBuilder(requireActivity()) - .setTitle(R.string.preferences_storage__clear_message_history) - .setMessage(R.string.preferences_storage__this_will_delete_all_message_history_and_media_from_your_device) - .setPositiveButton(R.string.delete, (d, w) -> showAreYouReallySure()) - .setNegativeButton(android.R.string.cancel, null) - .show(); - - return true; - } - - private void showAreYouReallySure() { - new MaterialAlertDialogBuilder(requireActivity()) - .setTitle(R.string.preferences_storage__are_you_sure_you_want_to_delete_all_message_history) - .setMessage(R.string.preferences_storage__all_message_history_will_be_permanently_removed_this_action_cannot_be_undone) - .setPositiveButton(R.string.preferences_storage__delete_all_now, (d, w) -> { - SignalExecutors.BOUNDED.execute(() -> { - SignalDatabase.threads().deleteAllConversations(); - ApplicationDependencies.getMessageNotifier().updateNotification(requireContext()); - }); - }) - .setNegativeButton(android.R.string.cancel, null) - .show(); - } - } - - public static class KeepMessagesConfiguration extends BaseSettingsFragment.Configuration implements SingleSelectSetting.SingleSelectSelectionChangedListener { - - @Override - public void configureAdapter(@NonNull BaseSettingsAdapter adapter) { - adapter.configureSingleSelect(this); - } - - @Override - public @NonNull MappingModelList getSettings() { - KeepMessagesDuration currentDuration = SignalStore.settings().getKeepMessagesDuration(); - return Stream.of(KeepMessagesDuration.values()) - .map(duration -> new SingleSelectSetting.Item(duration, activity.getString(duration.getStringResource()), null, duration.equals(currentDuration))) - .collect(MappingModelList.toMappingModelList()); - } - - @Override - public void onSelectionChanged(@NonNull Object selection) { - KeepMessagesDuration currentDuration = SignalStore.settings().getKeepMessagesDuration(); - KeepMessagesDuration newDuration = (KeepMessagesDuration) selection; - - if (newDuration.ordinal() > currentDuration.ordinal()) { - new MaterialAlertDialogBuilder(activity) - .setTitle(R.string.preferences_storage__delete_older_messages) - .setMessage(activity.getString(R.string.preferences_storage__this_will_permanently_delete_all_message_history_and_media, activity.getString(newDuration.getStringResource()))) - .setPositiveButton(R.string.delete, (d, w) -> updateTrimByTime(newDuration)) - .setNegativeButton(android.R.string.cancel, null) - .show(); - } else { - updateTrimByTime(newDuration); - } - } - - private void updateTrimByTime(@NonNull KeepMessagesDuration newDuration) { - SignalStore.settings().setKeepMessagesForDuration(newDuration); - updateSettingsList(); - ApplicationDependencies.getTrimThreadsByDateManager().scheduleIfNecessary(); - } - } - - public static class ConversationLengthLimitConfiguration extends BaseSettingsFragment.Configuration implements CustomizableSingleSelectSetting.CustomizableSingleSelectionListener { - - private static final int CUSTOM_LENGTH = -1; - - @Override - public void configureAdapter(@NonNull BaseSettingsAdapter adapter) { - adapter.configureSingleSelect(this); - adapter.configureCustomizableSingleSelect(this); - } - - @Override - public @NonNull MappingModelList getSettings() { - int trimLength = SignalStore.settings().isTrimByLengthEnabled() ? SignalStore.settings().getThreadTrimLength() : 0; - int[] options = activity.getResources().getIntArray(R.array.conversation_length_limit); - boolean hasSelection = false; - MappingModelList settings = new MappingModelList(); - - for (int option : options) { - boolean isSelected = option == trimLength; - String text = option == 0 ? activity.getString(R.string.preferences_storage__none) - : activity.getResources().getQuantityString(R.plurals.preferences_storage__s_messages_plural, option, NumberFormat.getInstance().format(option)); - - settings.add(new SingleSelectSetting.Item(option, text, null, isSelected)); - - hasSelection = hasSelection || isSelected; - } - - int currentValue = SignalStore.settings().getThreadTrimLength(); - settings.add(new CustomizableSingleSelectSetting.Item(CUSTOM_LENGTH, - activity.getString(R.string.preferences_storage__custom), - !hasSelection, - currentValue, - activity.getResources().getQuantityString(R.plurals.preferences_storage__s_messages_plural, currentValue, NumberFormat.getInstance().format(currentValue)))); - return settings; - } - - @SuppressLint("InflateParams") - @Override - public void onCustomizeClicked(@Nullable CustomizableSingleSelectSetting.Item item) { - boolean trimLengthEnabled = SignalStore.settings().isTrimByLengthEnabled(); - int trimLength = trimLengthEnabled ? SignalStore.settings().getThreadTrimLength() : 0; - - View view = LayoutInflater.from(activity).inflate(R.layout.customizable_setting_edit_text, null, false); - EditText editText = view.findViewById(R.id.customizable_setting_edit_text); - if (trimLength > 0) { - editText.setText(String.valueOf(trimLength)); - } - - AlertDialog dialog = new MaterialAlertDialogBuilder(activity) - .setTitle(R.string.preferences__conversation_length_limit) - .setView(view) - .setPositiveButton(android.R.string.ok, (d, w) -> onSelectionChanged(Integer.parseInt(editText.getText().toString()))) - .setNegativeButton(android.R.string.cancel, (d, w) -> updateSettingsList()) - .create(); - - dialog.setOnShowListener(d -> { - dialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(!TextUtils.isEmpty(editText.getText())); - editText.requestFocus(); - editText.addTextChangedListener(new TextWatcher() { - @Override - public void afterTextChanged(@NonNull Editable sequence) { - CharSequence trimmed = StringUtil.trimSequence(sequence); - if (TextUtils.isEmpty(trimmed)) { - sequence.replace(0, sequence.length(), ""); - } else { - try { - Integer.parseInt(trimmed.toString()); - dialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(true); - return; - } catch (NumberFormatException e) { - String onlyDigits = trimmed.toString().replaceAll("[^\\d]", ""); - if (!onlyDigits.equals(trimmed.toString())) { - sequence.replace(0, sequence.length(), onlyDigits); - } - } - } - dialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(false); - } - - @Override - public void beforeTextChanged(@NonNull CharSequence sequence, int start, int count, int after) {} - - @Override - public void onTextChanged(@NonNull CharSequence sequence, int start, int before, int count) {} - }); - }); - - dialog.show(); - } - - @Override - public void onSelectionChanged(@NonNull Object selection) { - boolean trimLengthEnabled = SignalStore.settings().isTrimByLengthEnabled(); - int trimLength = trimLengthEnabled ? SignalStore.settings().getThreadTrimLength() : 0; - int newTrimLength = (Integer) selection; - - if (newTrimLength > 0 && (!trimLengthEnabled || newTrimLength < trimLength)) { - new MaterialAlertDialogBuilder(activity) - .setTitle(R.string.preferences_storage__delete_older_messages) - .setMessage(activity.getResources().getQuantityString(R.plurals.preferences_storage__this_will_permanently_trim_all_conversations_to_the_d_most_recent_messages, newTrimLength, newTrimLength)) - .setPositiveButton(R.string.delete, (d, w) -> updateTrimByLength(newTrimLength)) - .setNegativeButton(android.R.string.cancel, null) - .show(); - } else if (newTrimLength == CUSTOM_LENGTH) { - onCustomizeClicked(null); - } else { - updateTrimByLength(newTrimLength); - } - } - - private void updateTrimByLength(int length) { - boolean restrictingChange = !SignalStore.settings().isTrimByLengthEnabled() || length < SignalStore.settings().getThreadTrimLength(); - - SignalStore.settings().setThreadTrimByLengthEnabled(length > 0); - SignalStore.settings().setThreadTrimLength(length); - updateSettingsList(); - - if (SignalStore.settings().isTrimByLengthEnabled() && restrictingChange) { - KeepMessagesDuration keepMessagesDuration = SignalStore.settings().getKeepMessagesDuration(); - - long trimBeforeDate = keepMessagesDuration != KeepMessagesDuration.FOREVER ? System.currentTimeMillis() - keepMessagesDuration.getDuration() - : ThreadTable.NO_TRIM_BEFORE_DATE_SET; - - SignalExecutors.BOUNDED.execute(() -> SignalDatabase.threads().trimAllThreads(length, trimBeforeDate)); - } - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/StoragePreferenceCategory.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/StoragePreferenceCategory.java deleted file mode 100644 index 18272fc2b7..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/StoragePreferenceCategory.java +++ /dev/null @@ -1,73 +0,0 @@ -package org.thoughtcrime.securesms.preferences.widgets; - -import android.content.Context; -import android.util.AttributeSet; -import android.widget.TextView; - -import androidx.preference.PreferenceCategory; -import androidx.preference.PreferenceViewHolder; - -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.util.Util; - -public final class StoragePreferenceCategory extends PreferenceCategory { - - private Runnable onFreeUpSpace; - private TextView totalSize; - private StorageGraphView storageGraphView; - private StorageGraphView.StorageBreakdown storage; - - public StoragePreferenceCategory(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - initialize(); - } - - public StoragePreferenceCategory(Context context, AttributeSet attrs) { - super(context, attrs); - initialize(); - } - - public StoragePreferenceCategory(Context context) { - super(context); - initialize(); - } - - private void initialize() { - setLayoutResource(R.layout.preference_storage_category); - } - - @Override - public void onBindViewHolder(PreferenceViewHolder view) { - super.onBindViewHolder(view); - - totalSize = (TextView) view.findViewById(R.id.total_size); - storageGraphView = (StorageGraphView) view.findViewById(R.id.storageGraphView); - - view.findViewById(R.id.free_up_space) - .setOnClickListener(v -> { - if (onFreeUpSpace != null) { - onFreeUpSpace.run(); - } - }); - - totalSize.setText(Util.getPrettyFileSize(0)); - - if (storage != null) { - setStorage(storage); - } - } - - public void setOnFreeUpSpace(Runnable onFreeUpSpace) { - this.onFreeUpSpace = onFreeUpSpace; - } - - public void setStorage(StorageGraphView.StorageBreakdown storage) { - this.storage = storage; - if (totalSize != null) { - totalSize.setText(Util.getPrettyFileSize(storage.getTotalSize())); - } - if (storageGraphView != null) { - storageGraphView.setStorageBreakdown(storage); - } - } -} diff --git a/app/src/main/res/layout/preference_storage_category.xml b/app/src/main/res/layout/preference_storage_category.xml index e267183cff..0ee71e9b8f 100644 --- a/app/src/main/res/layout/preference_storage_category.xml +++ b/app/src/main/res/layout/preference_storage_category.xml @@ -80,21 +80,15 @@ app:layout_constraintStart_toEndOf="@+id/legend_audio" app:layout_constraintTop_toTopOf="@+id/legend_audio" /> - diff --git a/app/src/main/res/navigation/app_settings.xml b/app/src/main/res/navigation/app_settings.xml index 20ea3a6c49..89c2902e48 100644 --- a/app/src/main/res/navigation/app_settings.xml +++ b/app/src/main/res/navigation/app_settings.xml @@ -367,7 +367,7 @@ Audio Review storage Delete older messages? - Clear message history? + + Delete message history? This will permanently delete all message history and media from your device that are older than %1$s. @@ -6825,6 +6826,15 @@ Unknown + + + Chat limits + + Delete message history + + Messages older than the selected time will be permanently deleted. + + Messages exceeding the selected length will be permanently deleted. diff --git a/app/src/main/res/xml/preferences_storage.xml b/app/src/main/res/xml/preferences_storage.xml deleted file mode 100644 index 24b2333552..0000000000 --- a/app/src/main/res/xml/preferences_storage.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/core-ui/src/main/java/org/signal/core/ui/Dialogs.kt b/core-ui/src/main/java/org/signal/core/ui/Dialogs.kt index 8ffc3af522..f3332c888b 100644 --- a/core-ui/src/main/java/org/signal/core/ui/Dialogs.kt +++ b/core-ui/src/main/java/org/signal/core/ui/Dialogs.kt @@ -86,8 +86,8 @@ object Dialogs { text = { Text(text = body) }, confirmButton = { TextButton(onClick = { - onConfirm() onDismiss() + onConfirm() }) { Text(text = confirm, color = confirmColor) } 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 bd9368a092..aea17b788f 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 @@ -26,16 +26,14 @@ import androidx.compose.ui.graphics.Color 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.text.TextStyle import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import org.signal.core.ui.theme.SignalTheme +import org.signal.core.ui.Rows.TextAndLabel object Rows { /** - * A row consisting of a radio button and text, which takes up the full - * width of the screen. + * A row consisting of a radio button and [text] and optional [label] in a [TextAndLabel]. */ @Composable fun RadioRow( @@ -44,6 +42,30 @@ object Rows { modifier: Modifier = Modifier, label: String? = null, enabled: Boolean = true + ) { + RadioRow( + content = { + TextAndLabel( + text = text, + label = label, + enabled = enabled + ) + }, + selected = selected, + modifier = modifier, + enabled = enabled + ) + } + + /** + * Customizable radio row that allows [content] to be provided as composable functions instead of primitives. + */ + @Composable + fun RadioRow( + content: @Composable RowScope.() -> Unit, + selected: Boolean, + modifier: Modifier = Modifier, + enabled: Boolean = true ) { Row( modifier = modifier @@ -58,70 +80,66 @@ object Rows { modifier = Modifier.padding(end = 24.dp) ) - Column( - modifier = Modifier.alpha(if (enabled) 1f else 0.4f) - ) { - Text( - text = text, - style = MaterialTheme.typography.bodyLarge - ) - - if (label != null) { - Text( - text = label, - fontSize = 14.sp, - lineHeight = 20.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } + content() } } + /** + * Row that positions [text] and optional [label] in a [TextAndLabel] to the side of a [Switch]. + */ @Composable fun ToggleRow( checked: Boolean, text: String, - textColor: Color = MaterialTheme.colorScheme.onSurface, onCheckChanged: (Boolean) -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + label: String? = null, + textColor: Color = MaterialTheme.colorScheme.onSurface, + enabled: Boolean = true ) { Row( modifier = modifier .fillMaxWidth() - .padding(defaultPadding()) + .padding(defaultPadding()), + verticalAlignment = CenterVertically ) { - Text( + TextAndLabel( text = text, - color = textColor, - modifier = Modifier - .weight(1f) - .align(CenterVertically) + label = label, + textColor = textColor, + enabled = enabled, + modifier = Modifier.padding(end = 16.dp) ) Switch( checked = checked, - onCheckedChange = onCheckChanged, - modifier = Modifier.align(CenterVertically) + enabled = enabled, + onCheckedChange = onCheckChanged ) } } + /** + * Text row that positions [text] and optional [label] in a [TextAndLabel] to the side of an optional [icon]. + */ @Composable fun TextRow( text: String, modifier: Modifier = Modifier, iconModifier: Modifier = Modifier, + label: String? = null, icon: Painter? = null, foregroundTint: Color = MaterialTheme.colorScheme.onSurface, - onClick: (() -> Unit)? = null + onClick: (() -> Unit)? = null, + enabled: Boolean = true ) { TextRow( text = { - Text( + TextAndLabel( text = text, - color = foregroundTint, - modifier = Modifier.align(CenterVertically) + label = label, + textColor = foregroundTint, + enabled = enabled ) }, icon = if (icon != null) { @@ -137,22 +155,28 @@ object Rows { null }, modifier = modifier, - onClick = onClick + onClick = onClick, + enabled = enabled ) } + /** + * Customizable text row that allows [text] and [icon] to be provided as composable functions instead of primitives. + */ @Composable fun TextRow( text: @Composable RowScope.() -> Unit, modifier: Modifier = Modifier, icon: (@Composable RowScope.() -> Unit)? = null, - onClick: (() -> Unit)? = null + onClick: (() -> Unit)? = null, + enabled: Boolean = true ) { Row( modifier = modifier .fillMaxWidth() - .clickable(enabled = onClick != null, onClick = onClick ?: {}) - .padding(defaultPadding()) + .clickable(enabled = enabled && onClick != null, onClick = onClick ?: {}) + .padding(defaultPadding()), + verticalAlignment = CenterVertically ) { if (icon != null) { icon() @@ -169,12 +193,45 @@ object Rows { vertical = 16.dp ) } + + /** + * Row component to position text above an optional label. + */ + @Composable + fun RowScope.TextAndLabel( + text: String, + modifier: Modifier = Modifier, + label: String? = null, + enabled: Boolean = true, + textColor: Color = MaterialTheme.colorScheme.onSurface, + textStyle: TextStyle = MaterialTheme.typography.bodyLarge + ) { + Column( + modifier = modifier + .alpha(if (enabled) 1f else 0.4f) + .weight(1f) + ) { + Text( + text = text, + style = textStyle, + color = textColor + ) + + if (label != null) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } } -@Preview +@SignalPreview @Composable private fun RadioRowPreview() { - SignalTheme(isDarkMode = false) { + Previews.Preview { var selected by remember { mutableStateOf(true) } Rows.RadioRow( @@ -188,15 +245,16 @@ private fun RadioRowPreview() { } } -@Preview +@SignalPreview @Composable private fun ToggleRowPreview() { - SignalTheme(isDarkMode = false) { + Previews.Preview { var checked by remember { mutableStateOf(false) } Rows.ToggleRow( checked = checked, text = "ToggleRow", + label = "ToggleRow label", onCheckChanged = { checked = it } @@ -210,7 +268,26 @@ private fun TextRowPreview() { Previews.Preview { Rows.TextRow( text = "TextRow", - icon = painterResource(id = android.R.drawable.ic_menu_camera) + icon = painterResource(id = android.R.drawable.ic_menu_camera), + onClick = {} ) } } + +@SignalPreview +@Composable +private fun TextAndLabelPreview() { + Previews.Preview { + Row { + TextAndLabel( + text = "TextAndLabel Text", + label = "TextAndLabel Label" + ) + TextAndLabel( + text = "TextAndLabel Text", + label = "TextAndLabel Label", + enabled = false + ) + } + } +}