From a95ebb2158ff692e844f53d94c2be2659450dd2f Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Mon, 2 Mar 2026 13:33:53 -0500 Subject: [PATCH] Add improved notification settings when muted. --- .../v2/exporters/ChatArchiveExporter.kt | 2 +- .../backup/v2/importer/ChatArchiveImporter.kt | 2 +- .../ConversationSettingsFragment.kt | 12 +- .../sounds/SoundsAndNotificationsEvent.kt | 57 ++++ .../SoundsAndNotificationsSettingsFragment.kt | 6 +- ...SoundsAndNotificationsSettingsFragment2.kt | 59 ++++ ...oundsAndNotificationsSettingsRepository.kt | 2 +- .../SoundsAndNotificationsSettingsScreen.kt | 309 ++++++++++++++++++ .../SoundsAndNotificationsSettingsState.kt | 2 +- .../SoundsAndNotificationsSettingsState2.kt | 23 ++ ...SoundsAndNotificationsSettingsViewModel.kt | 2 +- ...oundsAndNotificationsSettingsViewModel2.kt | 102 ++++++ .../securesms/database/RecipientTable.kt | 44 ++- .../database/RecipientTableCursorUtil.kt | 4 +- .../helpers/SignalDatabaseMigrations.kt | 6 +- .../V304_CallAndReplyNotificationSettings.kt | 15 + .../database/model/RecipientRecord.kt | 6 +- .../dialogs/GroupMentionSettingDialog.java | 18 +- .../notifications/DoNotDisturbUtil.java | 27 +- .../v2/NotificationStateProvider.kt | 28 +- .../securesms/recipients/Recipient.kt | 17 +- .../securesms/recipients/RecipientCreator.kt | 2 + .../BeginCallActionProcessorDelegate.java | 7 +- .../webrtc/IncomingCallActionProcessor.java | 21 +- .../IncomingGroupCallActionProcessor.java | 29 +- .../securesms/storage/StorageSyncModels.kt | 2 +- .../res/navigation/conversation_settings.xml | 35 +- app/src/main/res/values/arrays.xml | 10 + app/src/main/res/values/strings.xml | 19 +- .../database/RecipientDatabaseTestUtils.kt | 4 +- 30 files changed, 800 insertions(+), 72 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsEvent.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsSettingsFragment2.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsSettingsScreen.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsSettingsState2.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsSettingsViewModel2.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V304_CallAndReplyNotificationSettings.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/ChatArchiveExporter.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/ChatArchiveExporter.kt index 3d7e80162d..89ebf186be 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/ChatArchiveExporter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/ChatArchiveExporter.kt @@ -62,7 +62,7 @@ class ChatArchiveExporter(private val cursor: Cursor, private val db: SignalData expireTimerVersion = cursor.requireInt(RecipientTable.MESSAGE_EXPIRATION_TIME_VERSION), muteUntilMs = cursor.requireLong(RecipientTable.MUTE_UNTIL).takeIf { it > 0 }, markedUnread = ThreadTable.ReadStatus.deserialize(cursor.requireInt(ThreadTable.READ)) == ThreadTable.ReadStatus.FORCED_UNREAD, - dontNotifyForMentionsIfMuted = RecipientTable.MentionSetting.DO_NOT_NOTIFY.id == cursor.requireInt(RecipientTable.MENTION_SETTING), + dontNotifyForMentionsIfMuted = RecipientTable.NotificationSetting.DO_NOT_NOTIFY.id == cursor.requireInt(RecipientTable.MENTION_SETTING), style = ChatStyleConverter.constructRemoteChatStyle( db = db, chatColors = chatColors, diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/importer/ChatArchiveImporter.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/importer/ChatArchiveImporter.kt index 28b0642eaf..bd2ed9c7e2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/importer/ChatArchiveImporter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/importer/ChatArchiveImporter.kt @@ -60,7 +60,7 @@ object ChatArchiveImporter { .update( RecipientTable.TABLE_NAME, contentValuesOf( - RecipientTable.MENTION_SETTING to (if (chat.dontNotifyForMentionsIfMuted) RecipientTable.MentionSetting.DO_NOT_NOTIFY.id else RecipientTable.MentionSetting.ALWAYS_NOTIFY.id), + RecipientTable.MENTION_SETTING to (if (chat.dontNotifyForMentionsIfMuted) RecipientTable.NotificationSetting.DO_NOT_NOTIFY.id else RecipientTable.NotificationSetting.ALWAYS_NOTIFY.id), RecipientTable.MUTE_UNTIL to (chat.muteUntilMs ?: 0), RecipientTable.MESSAGE_EXPIRATION_TIME to (chat.expirationTimerMs?.milliseconds?.inWholeSeconds ?: 0), RecipientTable.MESSAGE_EXPIRATION_TIME_VERSION to chat.expireTimerVersion, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt index 2ae401e618..08e821e4f1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt @@ -592,11 +592,19 @@ class ConversationSettingsFragment : if (!state.recipient.isSelf) { clickPref( - title = DSLSettingsText.from(R.string.ConversationSettingsFragment__sounds_and_notifications), + title = if (RemoteConfig.internalUser) { + DSLSettingsText.from("${getString(R.string.ConversationSettingsFragment__sounds_and_notifications)} (Internal Only)") + } else { + DSLSettingsText.from(R.string.ConversationSettingsFragment__sounds_and_notifications) + }, icon = DSLSettingsIcon.from(R.drawable.symbol_speaker_24), isEnabled = !state.isDeprecatedOrUnregistered, onClick = { - val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToSoundsAndNotificationsSettingsFragment(state.recipient.id) + val action = if (RemoteConfig.internalUser) { + ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToSoundsAndNotificationsSettingsFragment2(state.recipient.id) + } else { + ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToSoundsAndNotificationsSettingsFragment(state.recipient.id) + } navController.safeNavigate(action) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsEvent.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsEvent.kt new file mode 100644 index 0000000000..c6408d2bda --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsEvent.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.conversation.sounds + +import org.thoughtcrime.securesms.database.RecipientTable.NotificationSetting + +/** + * Represents all user-driven actions that can occur on the Sounds & Notifications settings screen. + */ +sealed interface SoundsAndNotificationsEvent { + + /** + * Mutes notifications for this recipient until the given epoch-millisecond timestamp. + * + * @param muteUntil Epoch-millisecond timestamp after which notifications should resume. + * Use [Long.MAX_VALUE] to mute indefinitely. + */ + data class SetMuteUntil(val muteUntil: Long) : SoundsAndNotificationsEvent + + /** + * Clears any active mute, immediately restoring notifications for this recipient. + */ + data object Unmute : SoundsAndNotificationsEvent + + /** + * Updates the mention notification setting for this recipient. + * Only relevant for group conversations that support @mentions. + * + * @param setting The new [NotificationSetting] to apply for @mention notifications. + */ + data class SetMentionSetting(val setting: NotificationSetting) : SoundsAndNotificationsEvent + + /** + * Updates the call notification setting for this recipient. + * Controls whether incoming calls still produce notifications while the conversation is muted. + * + * @param setting The new [NotificationSetting] to apply for call notifications. + */ + data class SetCallNotificationSetting(val setting: NotificationSetting) : SoundsAndNotificationsEvent + + /** + * Updates the reply notification setting for this recipient. + * Controls whether replies directed at the current user still produce notifications while muted. + * + * @param setting The new [NotificationSetting] to apply for reply notifications. + */ + data class SetReplyNotificationSetting(val setting: NotificationSetting) : SoundsAndNotificationsEvent + + /** + * Signals that the user tapped the "Custom Notifications" row and wishes to navigate to the + * [custom notifications settings screen][org.thoughtcrime.securesms.components.settings.conversation.sounds.custom.CustomNotificationsSettingsFragment]. + */ + data object NavigateToCustomNotifications : SoundsAndNotificationsEvent +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsSettingsFragment.kt index db8f2a9335..05a70d019d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsSettingsFragment.kt @@ -82,7 +82,7 @@ class SoundsAndNotificationsSettingsFragment : ) if (state.hasMentionsSupport) { - val mentionSelection = if (state.mentionSetting == RecipientTable.MentionSetting.ALWAYS_NOTIFY) { + val mentionSelection = if (state.mentionSetting == RecipientTable.NotificationSetting.ALWAYS_NOTIFY) { 0 } else { 1 @@ -96,9 +96,9 @@ class SoundsAndNotificationsSettingsFragment : onSelected = { viewModel.setMentionSetting( if (it == 0) { - RecipientTable.MentionSetting.ALWAYS_NOTIFY + RecipientTable.NotificationSetting.ALWAYS_NOTIFY } else { - RecipientTable.MentionSetting.DO_NOT_NOTIFY + RecipientTable.NotificationSetting.DO_NOT_NOTIFY } ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsSettingsFragment2.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsSettingsFragment2.kt new file mode 100644 index 0000000000..471b743006 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsSettingsFragment2.kt @@ -0,0 +1,59 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.conversation.sounds + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.fragment.app.viewModels +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.Navigation +import org.signal.core.ui.compose.ComposeFragment +import org.thoughtcrime.securesms.MuteDialog +import org.thoughtcrime.securesms.components.settings.conversation.preferences.Utils.formatMutedUntil +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.util.navigation.safeNavigate + +class SoundsAndNotificationsSettingsFragment2 : ComposeFragment() { + + private val viewModel: SoundsAndNotificationsSettingsViewModel2 by viewModels( + factoryProducer = { + val recipientId = SoundsAndNotificationsSettingsFragment2Args.fromBundle(requireArguments()).recipientId + SoundsAndNotificationsSettingsViewModel2.Factory(recipientId) + } + ) + + @Composable + override fun FragmentContent() { + val state by viewModel.state.collectAsStateWithLifecycle() + + if (!state.channelConsistencyCheckComplete || state.recipientId == Recipient.UNKNOWN.id) { + return + } + + SoundsAndNotificationsSettingsScreen( + state = state, + formatMuteUntil = { it.formatMutedUntil(requireContext()) }, + onEvent = { event -> + when (event) { + is SoundsAndNotificationsEvent.NavigateToCustomNotifications -> { + val action = SoundsAndNotificationsSettingsFragment2Directions + .actionSoundsAndNotificationsSettingsFragment2ToCustomNotificationsSettingsFragment(state.recipientId) + Navigation.findNavController(requireView()).safeNavigate(action) + } + else -> viewModel.onEvent(event) + } + }, + onNavigationClick = { + requireActivity().onBackPressedDispatcher.onBackPressed() + }, + onMuteClick = { + MuteDialog.show(requireContext(), childFragmentManager, viewLifecycleOwner) { muteUntil -> + viewModel.onEvent(SoundsAndNotificationsEvent.SetMuteUntil(muteUntil)) + } + } + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsSettingsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsSettingsRepository.kt index 1a5193cf43..44ed7c3236 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsSettingsRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsSettingsRepository.kt @@ -25,7 +25,7 @@ class SoundsAndNotificationsSettingsRepository(private val context: Context) { } } - fun setMentionSetting(recipientId: RecipientId, mentionSetting: RecipientTable.MentionSetting) { + fun setMentionSetting(recipientId: RecipientId, mentionSetting: RecipientTable.NotificationSetting) { SignalExecutors.BOUNDED.execute { SignalDatabase.recipients.setMentionSetting(recipientId, mentionSetting) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsSettingsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsSettingsScreen.kt new file mode 100644 index 0000000000..091271e472 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsSettingsScreen.kt @@ -0,0 +1,309 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.conversation.sounds + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Dialogs +import org.signal.core.ui.compose.Dividers +import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.Rows +import org.signal.core.ui.compose.Scaffolds +import org.signal.core.ui.compose.SignalIcons +import org.signal.core.ui.compose.Texts +import org.signal.core.ui.compose.horizontalGutters +import org.signal.core.ui.compose.theme.SignalTheme +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.database.RecipientTable.NotificationSetting +import org.signal.core.ui.R as CoreUiR + +@Composable +fun SoundsAndNotificationsSettingsScreen( + state: SoundsAndNotificationsSettingsState2, + formatMuteUntil: (Long) -> String, + onEvent: (SoundsAndNotificationsEvent) -> Unit, + onNavigationClick: () -> Unit, + onMuteClick: () -> Unit +) { + val isMuted = state.muteUntil > 0 + var showUnmuteDialog by remember { mutableStateOf(false) } + + Scaffolds.Settings( + title = stringResource(R.string.ConversationSettingsFragment__sounds_and_notifications), + onNavigationClick = onNavigationClick, + navigationIcon = SignalIcons.ArrowStart.imageVector, + navigationContentDescription = stringResource(R.string.CallScreenTopBar__go_back) + ) { paddingValues -> + LazyColumn( + modifier = Modifier.padding(paddingValues) + ) { + // Custom notifications + item { + val summary = if (state.hasCustomNotificationSettings) { + stringResource(R.string.preferences_on) + } else { + stringResource(R.string.preferences_off) + } + + Rows.TextRow( + text = stringResource(R.string.SoundsAndNotificationsSettingsFragment__custom_notifications), + label = summary, + icon = painterResource(R.drawable.ic_speaker_24), + onClick = { onEvent(SoundsAndNotificationsEvent.NavigateToCustomNotifications) } + ) + } + + // Mute + item { + val muteSummary = if (isMuted) { + formatMuteUntil(state.muteUntil) + } else { + stringResource(R.string.SoundsAndNotificationsSettingsFragment__not_muted) + } + + val muteIcon = if (isMuted) { + R.drawable.ic_bell_disabled_24 + } else { + R.drawable.ic_bell_24 + } + + Rows.TextRow( + text = stringResource(R.string.SoundsAndNotificationsSettingsFragment__mute_notifications), + label = muteSummary, + icon = painterResource(muteIcon), + onClick = { + if (isMuted) showUnmuteDialog = true else onMuteClick() + } + ) + } + + // Divider + When muted section + item { + Dividers.Default() + } + + item { + Texts.SectionHeader(text = stringResource(R.string.SoundsAndNotificationsSettingsFragment__when_muted)) + } + + // Calls + item { + NotificationSettingRow( + title = stringResource(R.string.SoundsAndNotificationsSettingsFragment__calls), + dialogTitle = stringResource(R.string.SoundsAndNotificationsSettingsFragment__calls), + dialogMessage = stringResource(R.string.SoundsAndNotificationsSettingsFragment__calls_dialog_message), + icon = painterResource(CoreUiR.drawable.symbol_phone_24), + setting = state.callNotificationSetting, + onSelected = { onEvent(SoundsAndNotificationsEvent.SetCallNotificationSetting(it)) } + ) + } + + // Mentions (only for groups) + if (state.hasMentionsSupport) { + item { + NotificationSettingRow( + title = stringResource(R.string.SoundsAndNotificationsSettingsFragment__mentions), + dialogTitle = stringResource(R.string.SoundsAndNotificationsSettingsFragment__mentions), + dialogMessage = stringResource(R.string.SoundsAndNotificationsSettingsFragment__mentions_dialog_message), + icon = painterResource(R.drawable.ic_at_24), + setting = state.mentionSetting, + onSelected = { onEvent(SoundsAndNotificationsEvent.SetMentionSetting(it)) } + ) + } + } + + // Replies (only for groups) + if (state.hasMentionsSupport) { + item { + NotificationSettingRow( + title = stringResource(R.string.SoundsAndNotificationsSettingsFragment__replies_to_you), + dialogTitle = stringResource(R.string.SoundsAndNotificationsSettingsFragment__replies_to_you), + dialogMessage = stringResource(R.string.SoundsAndNotificationsSettingsFragment__replies_dialog_message), + icon = painterResource(R.drawable.symbol_reply_24), + setting = state.replyNotificationSetting, + onSelected = { onEvent(SoundsAndNotificationsEvent.SetReplyNotificationSetting(it)) } + ) + } + } + } + } + + if (showUnmuteDialog) { + Dialogs.SimpleAlertDialog( + title = Dialogs.NoTitle, + body = formatMuteUntil(state.muteUntil), + confirm = stringResource(R.string.ConversationSettingsFragment__unmute), + dismiss = stringResource(android.R.string.cancel), + onConfirm = { onEvent(SoundsAndNotificationsEvent.Unmute) }, + onDismiss = { showUnmuteDialog = false } + ) + } +} + +@Composable +private fun NotificationSettingRow( + title: String, + dialogTitle: String, + dialogMessage: String, + icon: Painter, + setting: NotificationSetting, + onSelected: (NotificationSetting) -> Unit +) { + var showDialog by remember { mutableStateOf(false) } + + val labels = arrayOf( + stringResource(R.string.SoundsAndNotificationsSettingsFragment__always_notify), + stringResource(R.string.SoundsAndNotificationsSettingsFragment__do_not_notify) + ) + val selectedLabel = if (setting == NotificationSetting.ALWAYS_NOTIFY) labels[0] else labels[1] + + Rows.TextRow( + text = title, + label = selectedLabel, + icon = icon, + onClick = { showDialog = true } + ) + + if (showDialog) { + NotificationSettingDialog( + title = dialogTitle, + message = dialogMessage, + labels = labels, + selectedIndex = if (setting == NotificationSetting.ALWAYS_NOTIFY) 0 else 1, + onDismiss = { showDialog = false }, + onSelected = { index -> + onSelected(if (index == 0) NotificationSetting.ALWAYS_NOTIFY else NotificationSetting.DO_NOT_NOTIFY) + showDialog = false + } + ) + } +} + +@Composable +private fun NotificationSettingDialog( + title: String, + message: String, + labels: Array, + selectedIndex: Int, + onDismiss: () -> Unit, + onSelected: (Int) -> Unit +) { + Dialog(onDismissRequest = onDismiss) { + Surface( + shape = AlertDialogDefaults.shape, + color = SignalTheme.colors.colorSurface2 + ) { + Column { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + modifier = Modifier + .padding(top = 24.dp) + .horizontalGutters() + ) + + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(top = 8.dp) + .horizontalGutters() + ) + + Column(modifier = Modifier.padding(top = 16.dp, bottom = 16.dp)) { + labels.forEachIndexed { index, label -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .defaultMinSize(minHeight = 48.dp) + .clickable { onSelected(index) } + .horizontalGutters() + ) { + RadioButton( + selected = index == selectedIndex, + onClick = { onSelected(index) } + ) + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = 16.dp) + ) + } + } + } + } + } + } +} + +@DayNightPreviews +@Composable +private fun SoundsAndNotificationsSettingsScreenMutedPreview() { + Previews.Preview { + SoundsAndNotificationsSettingsScreen( + state = SoundsAndNotificationsSettingsState2( + muteUntil = Long.MAX_VALUE, + callNotificationSetting = NotificationSetting.ALWAYS_NOTIFY, + mentionSetting = NotificationSetting.ALWAYS_NOTIFY, + replyNotificationSetting = NotificationSetting.DO_NOT_NOTIFY, + hasMentionsSupport = true, + hasCustomNotificationSettings = false, + channelConsistencyCheckComplete = true + ), + formatMuteUntil = { "Always" }, + onEvent = {}, + onNavigationClick = {}, + onMuteClick = {} + ) + } +} + +@DayNightPreviews +@Composable +private fun SoundsAndNotificationsSettingsScreenUnmutedPreview() { + Previews.Preview { + SoundsAndNotificationsSettingsScreen( + state = SoundsAndNotificationsSettingsState2( + muteUntil = 0L, + callNotificationSetting = NotificationSetting.ALWAYS_NOTIFY, + mentionSetting = NotificationSetting.ALWAYS_NOTIFY, + replyNotificationSetting = NotificationSetting.ALWAYS_NOTIFY, + hasMentionsSupport = false, + hasCustomNotificationSettings = true, + channelConsistencyCheckComplete = true + ), + formatMuteUntil = { "" }, + onEvent = {}, + onNavigationClick = {}, + onMuteClick = {} + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsSettingsState.kt index 82ac5ea907..e53c4309df 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsSettingsState.kt @@ -7,7 +7,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId data class SoundsAndNotificationsSettingsState( val recipientId: RecipientId = Recipient.UNKNOWN.id, val muteUntil: Long = 0L, - val mentionSetting: RecipientTable.MentionSetting = RecipientTable.MentionSetting.DO_NOT_NOTIFY, + val mentionSetting: RecipientTable.NotificationSetting = RecipientTable.NotificationSetting.DO_NOT_NOTIFY, val hasCustomNotificationSettings: Boolean = false, val hasMentionsSupport: Boolean = false, val channelConsistencyCheckComplete: Boolean = false diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsSettingsState2.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsSettingsState2.kt new file mode 100644 index 0000000000..5ad31c553d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsSettingsState2.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.conversation.sounds + +import org.thoughtcrime.securesms.database.RecipientTable.NotificationSetting +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId + +data class SoundsAndNotificationsSettingsState2( + val recipientId: RecipientId = Recipient.UNKNOWN.id, + val muteUntil: Long = 0L, + val mentionSetting: NotificationSetting = NotificationSetting.ALWAYS_NOTIFY, + val callNotificationSetting: NotificationSetting = NotificationSetting.ALWAYS_NOTIFY, + val replyNotificationSetting: NotificationSetting = NotificationSetting.ALWAYS_NOTIFY, + val hasCustomNotificationSettings: Boolean = false, + val hasMentionsSupport: Boolean = false, + val channelConsistencyCheckComplete: Boolean = false +) { + val isMuted = muteUntil > 0 +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsSettingsViewModel.kt index 562bbb5c01..418d2d3218 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsSettingsViewModel.kt @@ -38,7 +38,7 @@ class SoundsAndNotificationsSettingsViewModel( repository.setMuteUntil(recipientId, 0L) } - fun setMentionSetting(mentionSetting: RecipientTable.MentionSetting) { + fun setMentionSetting(mentionSetting: RecipientTable.NotificationSetting) { repository.setMentionSetting(recipientId, mentionSetting) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsSettingsViewModel2.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsSettingsViewModel2.kt new file mode 100644 index 0000000000..a642b3dad0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/SoundsAndNotificationsSettingsViewModel2.kt @@ -0,0 +1,102 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.conversation.sounds + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.thoughtcrime.securesms.database.RecipientTable.NotificationSetting +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.notifications.NotificationChannels +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientForeverObserver +import org.thoughtcrime.securesms.recipients.RecipientId + +class SoundsAndNotificationsSettingsViewModel2( + private val recipientId: RecipientId +) : ViewModel(), RecipientForeverObserver { + + private val _state = MutableStateFlow(SoundsAndNotificationsSettingsState2()) + val state: StateFlow = _state + + private val liveRecipient = Recipient.live(recipientId) + + init { + liveRecipient.observeForever(this) + onRecipientChanged(liveRecipient.get()) + + viewModelScope.launch(Dispatchers.IO) { + if (NotificationChannels.supported()) { + NotificationChannels.getInstance().ensureCustomChannelConsistency() + } + _state.update { it.copy(channelConsistencyCheckComplete = true) } + } + } + + override fun onRecipientChanged(recipient: Recipient) { + _state.update { + it.copy( + recipientId = recipientId, + muteUntil = if (recipient.isMuted) recipient.muteUntil else 0L, + mentionSetting = recipient.mentionSetting, + callNotificationSetting = recipient.callNotificationSetting, + replyNotificationSetting = recipient.replyNotificationSetting, + hasMentionsSupport = recipient.isPushV2Group, + hasCustomNotificationSettings = recipient.notificationChannel != null || !NotificationChannels.supported() + ) + } + } + + override fun onCleared() { + liveRecipient.removeForeverObserver(this) + } + + fun onEvent(event: SoundsAndNotificationsEvent) { + when (event) { + is SoundsAndNotificationsEvent.SetMuteUntil -> applySetMuteUntil(event.muteUntil) + is SoundsAndNotificationsEvent.Unmute -> applySetMuteUntil(0L) + is SoundsAndNotificationsEvent.SetMentionSetting -> applySetMentionSetting(event.setting) + is SoundsAndNotificationsEvent.SetCallNotificationSetting -> applySetCallNotificationSetting(event.setting) + is SoundsAndNotificationsEvent.SetReplyNotificationSetting -> applySetReplyNotificationSetting(event.setting) + is SoundsAndNotificationsEvent.NavigateToCustomNotifications -> Unit // Navigation handled by UI + } + } + + private fun applySetMuteUntil(muteUntil: Long) { + viewModelScope.launch(Dispatchers.IO) { + SignalDatabase.recipients.setMuted(recipientId, muteUntil) + } + } + + private fun applySetMentionSetting(setting: NotificationSetting) { + viewModelScope.launch(Dispatchers.IO) { + SignalDatabase.recipients.setMentionSetting(recipientId, setting) + } + } + + private fun applySetCallNotificationSetting(setting: NotificationSetting) { + viewModelScope.launch(Dispatchers.IO) { + SignalDatabase.recipients.setCallNotificationSetting(recipientId, setting) + } + } + + private fun applySetReplyNotificationSetting(setting: NotificationSetting) { + viewModelScope.launch(Dispatchers.IO) { + SignalDatabase.recipients.setReplyNotificationSetting(recipientId, setting) + } + } + + class Factory(private val recipientId: RecipientId) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return requireNotNull(modelClass.cast(SoundsAndNotificationsSettingsViewModel2(recipientId))) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt index 41cc51ce05..6b32c33f56 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt @@ -168,6 +168,8 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da const val STORAGE_SERVICE_ID = "storage_service_id" const val STORAGE_SERVICE_PROTO = "storage_service_proto" const val MENTION_SETTING = "mention_setting" + const val CALL_NOTIFICATION_SETTING = "call_notification_setting" + const val REPLY_NOTIFICATION_SETTING = "reply_notification_setting" const val CAPABILITIES = "capabilities" const val LAST_SESSION_RESET = "last_session_reset" const val WALLPAPER = "wallpaper" @@ -241,7 +243,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da $SEALED_SENDER_MODE INTEGER DEFAULT 0, $STORAGE_SERVICE_ID TEXT UNIQUE DEFAULT NULL, $STORAGE_SERVICE_PROTO TEXT DEFAULT NULL, - $MENTION_SETTING INTEGER DEFAULT ${MentionSetting.ALWAYS_NOTIFY.id}, + $MENTION_SETTING INTEGER DEFAULT ${NotificationSetting.ALWAYS_NOTIFY.id}, $CAPABILITIES INTEGER DEFAULT 0, $LAST_SESSION_RESET BLOB DEFAULT NULL, $WALLPAPER BLOB DEFAULT NULL, @@ -264,7 +266,9 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da $NICKNAME_JOINED_NAME TEXT DEFAULT NULL, $NOTE TEXT DEFAULT NULL, $MESSAGE_EXPIRATION_TIME_VERSION INTEGER DEFAULT 1 NOT NULL, - $KEY_TRANSPARENCY_DATA BLOB DEFAULT NULL + $KEY_TRANSPARENCY_DATA BLOB DEFAULT NULL, + $CALL_NOTIFICATION_SETTING INTEGER DEFAULT ${NotificationSetting.ALWAYS_NOTIFY.id}, + $REPLY_NOTIFICATION_SETTING INTEGER DEFAULT ${NotificationSetting.ALWAYS_NOTIFY.id} ) """ @@ -313,6 +317,8 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da SEALED_SENDER_MODE, STORAGE_SERVICE_ID, MENTION_SETTING, + CALL_NOTIFICATION_SETTING, + REPLY_NOTIFICATION_SETTING, CAPABILITIES, WALLPAPER, WALLPAPER_URI, @@ -1645,7 +1651,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da } } - fun setMentionSetting(id: RecipientId, mentionSetting: MentionSetting) { + fun setMentionSetting(id: RecipientId, mentionSetting: NotificationSetting) { val values = ContentValues().apply { put(MENTION_SETTING, mentionSetting.id) } @@ -1656,6 +1662,30 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da } } + fun setCallNotificationSetting(id: RecipientId, setting: NotificationSetting) { + val values = ContentValues().apply { + put(CALL_NOTIFICATION_SETTING, setting.id) + } + if (update(id, values)) { + // TODO rotate storageId once this is actually synced in storage service +// rotateStorageId(id) + AppDependencies.databaseObserver.notifyRecipientChanged(id) + StorageSyncHelper.scheduleSyncForDataChange() + } + } + + fun setReplyNotificationSetting(id: RecipientId, setting: NotificationSetting) { + val values = ContentValues().apply { + put(REPLY_NOTIFICATION_SETTING, setting.id) + } + if (update(id, values)) { + // TODO rotate storageId once this is actually synced in storage service +// rotateStorageId(id) + AppDependencies.databaseObserver.notifyRecipientChanged(id) + StorageSyncHelper.scheduleSyncForDataChange() + } + } + /** * Updates the profile key. * @@ -4147,7 +4177,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da SYSTEM_CONTACT_URI to secondaryRecord.systemContactUri, PROFILE_SHARING to (primaryRecord.profileSharing || secondaryRecord.profileSharing), CAPABILITIES to max(primaryRecord.capabilities.rawBits, secondaryRecord.capabilities.rawBits), - MENTION_SETTING to if (primaryRecord.mentionSetting != MentionSetting.ALWAYS_NOTIFY) primaryRecord.mentionSetting.id else secondaryRecord.mentionSetting.id, + MENTION_SETTING to if (primaryRecord.mentionSetting != NotificationSetting.ALWAYS_NOTIFY) primaryRecord.mentionSetting.id else secondaryRecord.mentionSetting.id, PNI_SIGNATURE_VERIFIED to pniVerified.toInt() ) @@ -4280,7 +4310,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da put(BLOCKED, if (groupV2.proto.blocked) "1" else "0") put(MUTE_UNTIL, groupV2.proto.mutedUntilTimestamp) put(STORAGE_SERVICE_ID, Base64.encodeWithPadding(groupV2.id.raw)) - put(MENTION_SETTING, if (groupV2.proto.dontNotifyForMentionsIfMuted) MentionSetting.DO_NOT_NOTIFY.id else MentionSetting.ALWAYS_NOTIFY.id) + put(MENTION_SETTING, if (groupV2.proto.dontNotifyForMentionsIfMuted) NotificationSetting.DO_NOT_NOTIFY.id else NotificationSetting.ALWAYS_NOTIFY.id) if (groupV2.proto.hasUnknownFields()) { put(STORAGE_SERVICE_PROTO, Base64.encodeWithPadding(groupV2.serializedUnknowns!!)) @@ -4861,12 +4891,12 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da } } - enum class MentionSetting(val id: Int) { + enum class NotificationSetting(val id: Int) { ALWAYS_NOTIFY(0), DO_NOT_NOTIFY(1); companion object { - fun fromId(id: Int): MentionSetting { + fun fromId(id: Int): NotificationSetting { return entries[id] } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTableCursorUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTableCursorUtil.kt index 3df1f1f7d7..ecaec9e3c6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTableCursorUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTableCursorUtil.kt @@ -150,7 +150,9 @@ object RecipientTableCursorUtil { sealedSenderAccessMode = RecipientTable.SealedSenderAccessMode.fromMode(cursor.requireInt(RecipientTable.SEALED_SENDER_MODE)), capabilities = readCapabilities(cursor), storageId = Base64.decodeNullableOrThrow(cursor.requireString(RecipientTable.STORAGE_SERVICE_ID)), - mentionSetting = RecipientTable.MentionSetting.fromId(cursor.requireInt(RecipientTable.MENTION_SETTING)), + mentionSetting = RecipientTable.NotificationSetting.fromId(cursor.requireInt(RecipientTable.MENTION_SETTING)), + callNotificationSetting = RecipientTable.NotificationSetting.fromId(cursor.requireInt(RecipientTable.CALL_NOTIFICATION_SETTING)), + replyNotificationSetting = RecipientTable.NotificationSetting.fromId(cursor.requireInt(RecipientTable.REPLY_NOTIFICATION_SETTING)), wallpaper = chatWallpaper, chatColors = chatColors, avatarColor = avatarColor, diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt index 38398e7523..d707337c7a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt @@ -157,6 +157,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V300_AddKeyTranspar import org.thoughtcrime.securesms.database.helpers.migration.V301_RemoveCallLinkEpoch import org.thoughtcrime.securesms.database.helpers.migration.V302_AddDeletedByColumn import org.thoughtcrime.securesms.database.helpers.migration.V303_CaseInsensitiveUsernames +import org.thoughtcrime.securesms.database.helpers.migration.V304_CallAndReplyNotificationSettings import org.thoughtcrime.securesms.database.SQLiteDatabase as SignalSqliteDatabase /** @@ -320,10 +321,11 @@ object SignalDatabaseMigrations { 300 to V300_AddKeyTransparencyColumn, 301 to V301_RemoveCallLinkEpoch, 302 to V302_AddDeletedByColumn, - 303 to V303_CaseInsensitiveUsernames + 303 to V303_CaseInsensitiveUsernames, + 304 to V304_CallAndReplyNotificationSettings ) - const val DATABASE_VERSION = 303 + const val DATABASE_VERSION = 304 @JvmStatic fun migrate(context: Application, db: SignalSqliteDatabase, oldVersion: Int, newVersion: Int) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V304_CallAndReplyNotificationSettings.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V304_CallAndReplyNotificationSettings.kt new file mode 100644 index 0000000000..aa637dd49b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V304_CallAndReplyNotificationSettings.kt @@ -0,0 +1,15 @@ +package org.thoughtcrime.securesms.database.helpers.migration + +import android.app.Application +import org.thoughtcrime.securesms.database.SQLiteDatabase + +/** + * Adds per-conversation notification settings for calls and replies when muted. + */ +@Suppress("ClassName") +object V304_CallAndReplyNotificationSettings : SignalDatabaseMigration { + override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + db.execSQL("ALTER TABLE recipient ADD COLUMN call_notification_setting INTEGER DEFAULT 0") + db.execSQL("ALTER TABLE recipient ADD COLUMN reply_notification_setting INTEGER DEFAULT 0") + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/RecipientRecord.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/RecipientRecord.kt index 375105268b..66296b53a6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/RecipientRecord.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/RecipientRecord.kt @@ -11,7 +11,7 @@ import org.thoughtcrime.securesms.conversation.colors.AvatarColor import org.thoughtcrime.securesms.conversation.colors.ChatColors import org.thoughtcrime.securesms.database.IdentityTable.VerifiedStatus import org.thoughtcrime.securesms.database.RecipientTable -import org.thoughtcrime.securesms.database.RecipientTable.MentionSetting +import org.thoughtcrime.securesms.database.RecipientTable.NotificationSetting import org.thoughtcrime.securesms.database.RecipientTable.PhoneNumberSharingState import org.thoughtcrime.securesms.database.RecipientTable.RegisteredState import org.thoughtcrime.securesms.database.RecipientTable.SealedSenderAccessMode @@ -64,7 +64,9 @@ data class RecipientRecord( val sealedSenderAccessMode: SealedSenderAccessMode, val capabilities: Capabilities, val storageId: ByteArray?, - val mentionSetting: MentionSetting, + val mentionSetting: NotificationSetting, + val callNotificationSetting: NotificationSetting, + val replyNotificationSetting: NotificationSetting, val wallpaper: ChatWallpaper?, val chatColors: ChatColors?, val avatarColor: AvatarColor, diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/dialogs/GroupMentionSettingDialog.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/dialogs/GroupMentionSettingDialog.java index 3b4fa92e83..e5af3bf81b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/dialogs/GroupMentionSettingDialog.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/dialogs/GroupMentionSettingDialog.java @@ -14,11 +14,11 @@ import androidx.core.util.Consumer; import com.google.android.material.dialog.MaterialAlertDialogBuilder; import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.database.RecipientTable.MentionSetting; +import org.thoughtcrime.securesms.database.RecipientTable.NotificationSetting; public final class GroupMentionSettingDialog { - public static void show(@NonNull Context context, @NonNull MentionSetting mentionSetting, @Nullable Consumer callback) { + public static void show(@NonNull Context context, @NonNull NotificationSetting mentionSetting, @Nullable Consumer callback) { SelectionCallback selectionCallback = new SelectionCallback(mentionSetting, callback); new MaterialAlertDialogBuilder(context) @@ -30,7 +30,7 @@ public final class GroupMentionSettingDialog { } @SuppressLint("InflateParams") - private static View getView(@NonNull Context context, @NonNull MentionSetting mentionSetting, @NonNull SelectionCallback selectionCallback) { + private static View getView(@NonNull Context context, @NonNull NotificationSetting mentionSetting, @NonNull SelectionCallback selectionCallback) { View root = LayoutInflater.from(context).inflate(R.layout.group_mention_setting_dialog, null, false); CheckedTextView alwaysNotify = root.findViewById(R.id.group_mention_setting_always_notify); CheckedTextView dontNotify = root.findViewById(R.id.group_mention_setting_dont_notify); @@ -40,9 +40,9 @@ public final class GroupMentionSettingDialog { dontNotify.setChecked(dontNotify == v); if (alwaysNotify.isChecked()) { - selectionCallback.selection = MentionSetting.ALWAYS_NOTIFY; + selectionCallback.selection = NotificationSetting.ALWAYS_NOTIFY; } else if (dontNotify.isChecked()) { - selectionCallback.selection = MentionSetting.DO_NOT_NOTIFY; + selectionCallback.selection = NotificationSetting.DO_NOT_NOTIFY; } }; @@ -63,11 +63,11 @@ public final class GroupMentionSettingDialog { private static class SelectionCallback implements DialogInterface.OnClickListener { - @NonNull private final MentionSetting previousMentionSetting; - @NonNull private MentionSetting selection; - @Nullable private final Consumer callback; + @NonNull private final NotificationSetting previousMentionSetting; + @NonNull private NotificationSetting selection; + @Nullable private final Consumer callback; - public SelectionCallback(@NonNull MentionSetting previousMentionSetting, @Nullable Consumer callback) { + public SelectionCallback(@NonNull NotificationSetting previousMentionSetting, @Nullable Consumer callback) { this.previousMentionSetting = previousMentionSetting; this.selection = previousMentionSetting; this.callback = callback; diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/DoNotDisturbUtil.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/DoNotDisturbUtil.java index 6e3b341a57..d1752fe943 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/DoNotDisturbUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/DoNotDisturbUtil.java @@ -13,6 +13,7 @@ import androidx.annotation.WorkerThread; import org.signal.core.util.CursorUtil; import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.RecipientTable; import org.thoughtcrime.securesms.database.SignalDatabase; import org.signal.core.ui.permissions.Permissions; import org.thoughtcrime.securesms.recipients.Recipient; @@ -27,9 +28,31 @@ public final class DoNotDisturbUtil { private DoNotDisturbUtil() { } + /** + * Checks whether the user should be disturbed with a call from the given recipient, + * taking into account the recipient's mute and call notification settings as well as + * the system Do Not Disturb state. + * + * For group recipients, only the system interruption filter is checked (no contact priority). + * For 1:1 recipients, the full DND policy including contact priority is evaluated. + */ @WorkerThread @SuppressLint("SwitchIntDef") - public static boolean shouldDisturbUserWithCall(@NonNull Context context) { + public static boolean shouldDisturbUserWithCall(@NonNull Context context, @NonNull Recipient recipient) { + if (recipient.isMuted() && recipient.getCallNotificationSetting() == RecipientTable.NotificationSetting.DO_NOT_NOTIFY) { + return false; + } + + if (recipient.isGroup()) { + return checkSystemDnd(context); + } else { + return checkSystemDndWithContactPriority(context, recipient); + } + } + + @WorkerThread + @SuppressLint("SwitchIntDef") + private static boolean checkSystemDnd(@NonNull Context context) { NotificationManager notificationManager = ServiceUtil.getNotificationManager(context); switch (notificationManager.getCurrentInterruptionFilter()) { @@ -43,7 +66,7 @@ public final class DoNotDisturbUtil { @WorkerThread @SuppressLint("SwitchIntDef") - public static boolean shouldDisturbUserWithCall(@NonNull Context context, @NonNull Recipient recipient) { + private static boolean checkSystemDndWithContactPriority(@NonNull Context context, @NonNull Recipient recipient) { NotificationManager notificationManager = ServiceUtil.getNotificationManager(context); switch (notificationManager.getCurrentInterruptionFilter()) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationStateProvider.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationStateProvider.kt index cd37225b9d..24c50d2a97 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationStateProvider.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationStateProvider.kt @@ -167,13 +167,13 @@ object NotificationStateProvider { isUnreadMessage && !messageRecord.isOutgoing && isGroupStoryReply && - (isParentStorySentBySelf || messageRecord.hasSelfMentionOrQuoteOfSelf() || (hasSelfRepliedToStory && !messageRecord.isStoryReaction())) + (isParentStorySentBySelf || messageRecord.hasGroupQuoteOrSelfMention() || (hasSelfRepliedToStory && !messageRecord.isStoryReaction())) fun includeMessage(notificationProfile: NotificationProfile?): MessageInclusion { return if (isUnreadIncoming || stickyThread || isNotifiableGroupStoryMessage || isIncomingMissedCall) { - if (threadRecipient.isMuted && (threadRecipient.isDoNotNotifyMentions || !messageRecord.shouldBreakThroughMute(threadRecipient))) { + if (threadRecipient.isMuted && !breaksThroughMute()) { MessageInclusion.MUTE_FILTERED - } else if (notificationProfile != null && !notificationProfile.isRecipientAllowed(threadRecipient.id) && !(notificationProfile.allowAllMentions && messageRecord.shouldBreakThroughMute(threadRecipient))) { + } else if (notificationProfile != null && !notificationProfile.isRecipientAllowed(threadRecipient.id) && !(notificationProfile.allowAllMentions && messageRecord.hasGroupQuoteOrSelfMention())) { MessageInclusion.PROFILE_FILTERED } else { MessageInclusion.INCLUDE @@ -183,6 +183,19 @@ object NotificationStateProvider { } } + private fun breaksThroughMute(): Boolean { + return when { + isIncomingMissedCall -> threadRecipient.callNotificationSetting == RecipientTable.NotificationSetting.ALWAYS_NOTIFY + messageRecord.hasSelfMention() -> threadRecipient.mentionSetting == RecipientTable.NotificationSetting.ALWAYS_NOTIFY + messageRecord.isQuoteOfSelf() -> threadRecipient.replyNotificationSetting == RecipientTable.NotificationSetting.ALWAYS_NOTIFY + else -> false + } + } + + private fun MessageRecord.isQuoteOfSelf(): Boolean { + return this is MmsMessageRecord && quote?.author == Recipient.self().id + } + fun includeReaction(reaction: ReactionRecord, notificationProfile: NotificationProfile?): MessageInclusion { return if (threadRecipient.isMuted) { MessageInclusion.MUTE_FILTERED @@ -207,19 +220,12 @@ object NotificationStateProvider { } } - private val Recipient.isDoNotNotifyMentions: Boolean - get() = mentionSetting == RecipientTable.MentionSetting.DO_NOT_NOTIFY - - private fun MessageRecord.shouldBreakThroughMute(threadRecipient: Recipient): Boolean { + private fun MessageRecord.hasGroupQuoteOrSelfMention(): Boolean { if (!threadRecipient.isGroup) { return false } return hasSelfMention() || (this is MmsMessageRecord && quote?.author == Recipient.self().id) } - - private fun MessageRecord.hasSelfMentionOrQuoteOfSelf(): Boolean { - return hasSelfMention() || (this is MmsMessageRecord && quote?.author == Recipient.self().id) - } } private enum class MessageInclusion { diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.kt index c4ff07ea0a..292425486d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.kt @@ -28,8 +28,8 @@ import org.thoughtcrime.securesms.conversation.colors.AvatarColor import org.thoughtcrime.securesms.conversation.colors.ChatColors import org.thoughtcrime.securesms.conversation.colors.ChatColors.Id.Auto import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette -import org.thoughtcrime.securesms.database.RecipientTable.MentionSetting import org.thoughtcrime.securesms.database.RecipientTable.MissingRecipientException +import org.thoughtcrime.securesms.database.RecipientTable.NotificationSetting import org.thoughtcrime.securesms.database.RecipientTable.PhoneNumberSharingState import org.thoughtcrime.securesms.database.RecipientTable.RegisteredState import org.thoughtcrime.securesms.database.RecipientTable.SealedSenderAccessMode @@ -48,6 +48,7 @@ import org.thoughtcrime.securesms.phonenumbers.NumberUtil import org.thoughtcrime.securesms.profiles.ProfileName import org.thoughtcrime.securesms.recipients.Recipient.Companion.external import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId +import org.thoughtcrime.securesms.util.RemoteConfig import org.thoughtcrime.securesms.util.SignalE164Util import org.thoughtcrime.securesms.util.UsernameUtil.isValidUsernameForSearch import org.thoughtcrime.securesms.wallpaper.ChatWallpaper @@ -103,7 +104,9 @@ class Recipient( private val sealedSenderAccessModeValue: SealedSenderAccessMode = SealedSenderAccessMode.UNKNOWN, private val capabilities: RecipientRecord.Capabilities = RecipientRecord.Capabilities.UNKNOWN, val storageId: ByteArray? = null, - val mentionSetting: MentionSetting = MentionSetting.ALWAYS_NOTIFY, + val mentionSetting: NotificationSetting = NotificationSetting.ALWAYS_NOTIFY, + private val callNotificationSettingValue: NotificationSetting = NotificationSetting.ALWAYS_NOTIFY, + private val replyNotificationSettingValue: NotificationSetting = NotificationSetting.ALWAYS_NOTIFY, private val wallpaperValue: ChatWallpaper? = null, private val chatColorsValue: ChatColors? = null, val avatarColor: AvatarColor = AvatarColor.UNKNOWN, @@ -329,6 +332,14 @@ class Recipient( /** The notification channel, if both set and supported by the system. Otherwise null. */ val notificationChannel: String? = if (!NotificationChannels.supported()) null else notificationChannelValue + /** Whether calls should break through mute for this recipient. */ + val callNotificationSetting: NotificationSetting + get() = if (RemoteConfig.internalUser) callNotificationSettingValue else NotificationSetting.ALWAYS_NOTIFY + + /** Whether replies should break through mute for this recipient. Only applicable to groups. */ + val replyNotificationSetting: NotificationSetting + get() = if (groupIdValue == null) NotificationSetting.DO_NOT_NOTIFY else if (RemoteConfig.internalUser) replyNotificationSettingValue else mentionSetting + /** The state around whether we can send sealed sender to this user. */ val sealedSenderAccessMode: SealedSenderAccessMode = if (pni.isPresent && pni == serviceId) { SealedSenderAccessMode.DISABLED @@ -810,6 +821,8 @@ class Recipient( notificationChannelValue == other.notificationChannelValue && sealedSenderAccessModeValue == other.sealedSenderAccessModeValue && mentionSetting == other.mentionSetting && + callNotificationSettingValue == other.callNotificationSettingValue && + replyNotificationSettingValue == other.replyNotificationSettingValue && wallpaperValue == other.wallpaperValue && chatColorsValue == other.chatColorsValue && avatarColor == other.avatarColor && diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientCreator.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientCreator.kt index 9cf2ae9498..6b5207cc4a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientCreator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientCreator.kt @@ -185,6 +185,8 @@ object RecipientCreator { capabilities = record.capabilities, storageId = record.storageId, mentionSetting = record.mentionSetting, + callNotificationSettingValue = record.callNotificationSetting, + replyNotificationSettingValue = record.replyNotificationSetting, wallpaperValue = record.wallpaper?.validate(), chatColorsValue = record.chatColors, avatarColor = avatarColor ?: record.avatarColor, diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/BeginCallActionProcessorDelegate.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/BeginCallActionProcessorDelegate.java index 454eeb3523..6dd7947657 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/BeginCallActionProcessorDelegate.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/BeginCallActionProcessorDelegate.java @@ -13,6 +13,8 @@ import org.thoughtcrime.securesms.events.CallParticipant; import org.thoughtcrime.securesms.events.CallParticipantId; import org.thoughtcrime.securesms.events.WebRtcViewModel; import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.notifications.DoNotDisturbUtil; +import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.ringrtc.RemotePeer; import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState; import org.signal.core.util.Util; @@ -116,8 +118,11 @@ public class BeginCallActionProcessorDelegate extends WebRtcActionProcessor { boolean isRemoteVideoOffer = currentState.getCallSetupState(remotePeer).isRemoteVideoOffer(); + Recipient recipient = remotePeer.getRecipient(); - webRtcInteractor.setCallInProgressNotification(TYPE_INCOMING_CONNECTING, remotePeer, isRemoteVideoOffer); + if (DoNotDisturbUtil.shouldDisturbUserWithCall(context.getApplicationContext(), recipient)) { + webRtcInteractor.setCallInProgressNotification(TYPE_INCOMING_CONNECTING, remotePeer, isRemoteVideoOffer); + } webRtcInteractor.retrieveTurnServers(remotePeer); webRtcInteractor.initializeAudioForCall(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IncomingCallActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IncomingCallActionProcessor.java index fd6e811ace..37019d88b7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IncomingCallActionProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IncomingCallActionProcessor.java @@ -240,18 +240,23 @@ public class IncomingCallActionProcessor extends DeviceAwareActionProcessor { CallTable.Direction.INCOMING, CallTable.Event.ONGOING); + if (!shouldDisturbUserWithCall) { + Log.i(TAG, "Silently ignoring call due to mute settings."); + return currentState.builder() + .changeCallInfoState() + .callState(WebRtcViewModel.State.CALL_INCOMING) + .build(); + } - if (shouldDisturbUserWithCall) { - webRtcInteractor.updatePhoneState(LockManager.PhoneState.INTERACTIVE); - boolean started = webRtcInteractor.startWebRtcCallActivityIfPossible(); - if (!started) { - Log.i(TAG, "Unable to start call activity due to OS version or not being in the foreground"); - AppForegroundObserver.addListener(webRtcInteractor.getForegroundListener()); - } + webRtcInteractor.updatePhoneState(LockManager.PhoneState.INTERACTIVE); + boolean started = webRtcInteractor.startWebRtcCallActivityIfPossible(); + if (!started) { + Log.i(TAG, "Unable to start call activity due to OS version or not being in the foreground"); + AppForegroundObserver.addListener(webRtcInteractor.getForegroundListener()); } boolean isCallNotificationsEnabled = SignalStore.settings().isCallNotificationsEnabled() && NotificationChannels.getInstance().areNotificationsEnabled(); - if (shouldDisturbUserWithCall && isCallNotificationsEnabled) { + if (isCallNotificationsEnabled) { Uri ringtone = recipient.resolve().getCallRingtone(); RecipientTable.VibrateState vibrateState = recipient.resolve().getCallVibrate(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IncomingGroupCallActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IncomingGroupCallActionProcessor.java index 3290caefc8..f1436b763a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IncomingGroupCallActionProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IncomingGroupCallActionProcessor.java @@ -121,33 +121,36 @@ public final class IncomingGroupCallActionProcessor extends DeviceAwareActionPro currentState = WebRtcVideoUtil.initializeVideo(context, webRtcInteractor.getCameraEventListener(), currentState, RemotePeer.GROUP_CALL_ID.longValue()); - webRtcInteractor.setCallInProgressNotification(TYPE_INCOMING_RINGING, remotePeerGroup, true); webRtcInteractor.initializeAudioForCall(); - boolean shouldDisturbUserWithCall = DoNotDisturbUtil.shouldDisturbUserWithCall(context.getApplicationContext()); - if (shouldDisturbUserWithCall) { + boolean shouldDisturbUserWithCall = DoNotDisturbUtil.shouldDisturbUserWithCall(context.getApplicationContext(), recipient.resolve()); + + if (!shouldDisturbUserWithCall) { + Log.i(TAG, "Silently ignoring group ring due to mute settings."); + } else { + webRtcInteractor.setCallInProgressNotification(TYPE_INCOMING_RINGING, remotePeerGroup, true); webRtcInteractor.updatePhoneState(LockManager.PhoneState.INTERACTIVE); boolean started = webRtcInteractor.startWebRtcCallActivityIfPossible(); if (!started) { Log.i(TAG, "Unable to start call activity due to OS version or not being in the foreground"); AppForegroundObserver.addListener(webRtcInteractor.getForegroundListener()); } - } - boolean isCallNotificationsEnabled = SignalStore.settings().isCallNotificationsEnabled() && NotificationChannels.getInstance().areNotificationsEnabled(); - if (shouldDisturbUserWithCall && isCallNotificationsEnabled) { - Uri ringtone = recipient.resolve().getCallRingtone(); - RecipientTable.VibrateState vibrateState = recipient.resolve().getCallVibrate(); + boolean isCallNotificationsEnabled = SignalStore.settings().isCallNotificationsEnabled() && NotificationChannels.getInstance().areNotificationsEnabled(); + if (isCallNotificationsEnabled) { + Uri ringtone = recipient.resolve().getCallRingtone(); + RecipientTable.VibrateState vibrateState = recipient.resolve().getCallVibrate(); - if (ringtone == null) { - ringtone = SignalStore.settings().getCallRingtone(); + if (ringtone == null) { + ringtone = SignalStore.settings().getCallRingtone(); + } + + webRtcInteractor.startIncomingRinger(ringtone, vibrateState == RecipientTable.VibrateState.ENABLED || (vibrateState == RecipientTable.VibrateState.DEFAULT && SignalStore.settings().isCallVibrateEnabled())); } - webRtcInteractor.startIncomingRinger(ringtone, vibrateState == RecipientTable.VibrateState.ENABLED || (vibrateState == RecipientTable.VibrateState.DEFAULT && SignalStore.settings().isCallVibrateEnabled())); + webRtcInteractor.registerPowerButtonReceiver(); } - webRtcInteractor.registerPowerButtonReceiver(); - return currentState.builder() .changeCallSetupState(RemotePeer.GROUP_CALL_ID) .isRemoteVideoOffer(true) diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.kt b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.kt index 9467e26eb4..224fd0afce 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.kt @@ -249,7 +249,7 @@ object StorageSyncModels { archived = recipient.syncExtras.isArchived markedUnread = recipient.syncExtras.isForcedUnread mutedUntilTimestamp = recipient.muteUntil - dontNotifyForMentionsIfMuted = recipient.mentionSetting == RecipientTable.MentionSetting.DO_NOT_NOTIFY + dontNotifyForMentionsIfMuted = recipient.mentionSetting == RecipientTable.NotificationSetting.DO_NOT_NOTIFY hideStory = recipient.extras != null && recipient.extras.hideStory() avatarColor = localToRemoteAvatarColor(recipient.avatarColor) storySendMode = when (groups.getShowAsStoryState(groupId)) { diff --git a/app/src/main/res/navigation/conversation_settings.xml b/app/src/main/res/navigation/conversation_settings.xml index d190183a47..6fa3016a59 100644 --- a/app/src/main/res/navigation/conversation_settings.xml +++ b/app/src/main/res/navigation/conversation_settings.xml @@ -66,6 +66,20 @@ + + + + + + + android:name="org.thoughtcrime.securesms.components.settings.conversation.sounds.SoundsAndNotificationsSettingsFragment" + tools:layout="@layout/dsl_settings_fragment"> + + + + + + + + diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 30885b82c4..9279d90cf3 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -392,6 +392,16 @@ @string/SoundsAndNotificationsSettingsFragment__do_not_notify + + @string/SoundsAndNotificationsSettingsFragment__always_notify + @string/SoundsAndNotificationsSettingsFragment__do_not_notify + + + + @string/SoundsAndNotificationsSettingsFragment__always_notify + @string/SoundsAndNotificationsSettingsFragment__do_not_notify + + 100 150 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 62f223fc8f..4834ff0dd6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -6006,13 +6006,30 @@ Who can send messages and start calls? + Mute notifications + Not muted + Mentions + Always notify - + Do not notify + Custom notifications + + When muted + + Calls + + Ring and receive notifications when a call is started in muted chats. + + Receive notifications when you are mentioned in muted chats. + + Replies to you + + Receive notifications when someone replies to your messages in muted chats. Recently used diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/RecipientDatabaseTestUtils.kt b/app/src/test/java/org/thoughtcrime/securesms/database/RecipientDatabaseTestUtils.kt index 4f409f6df7..728cfdaa86 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/database/RecipientDatabaseTestUtils.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/database/RecipientDatabaseTestUtils.kt @@ -61,7 +61,7 @@ object RecipientDatabaseTestUtils { sealedSenderAccessMode: RecipientTable.SealedSenderAccessMode = RecipientTable.SealedSenderAccessMode.UNKNOWN, capabilities: Long = 0L, storageId: ByteArray? = null, - mentionSetting: RecipientTable.MentionSetting = RecipientTable.MentionSetting.ALWAYS_NOTIFY, + mentionSetting: RecipientTable.NotificationSetting = RecipientTable.NotificationSetting.ALWAYS_NOTIFY, wallpaper: ChatWallpaper? = null, chatColors: ChatColors? = null, avatarColor: AvatarColor = AvatarColor.A100, @@ -128,6 +128,8 @@ object RecipientDatabaseTestUtils { ), storageId = storageId, mentionSetting = mentionSetting, + callNotificationSetting = RecipientTable.NotificationSetting.ALWAYS_NOTIFY, + replyNotificationSetting = RecipientTable.NotificationSetting.ALWAYS_NOTIFY, wallpaper = wallpaper, chatColors = chatColors, avatarColor = avatarColor,