Convert CustomNotificationSettings to compose.

This commit is contained in:
Alex Hart
2026-09-10 17:10:14 -04:00
committed by Cody Henthorne
parent f4a22c5854
commit 832da0da4a
11 changed files with 813 additions and 300 deletions
@@ -0,0 +1,73 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.conversation.sounds.custom
import android.net.Uri
import org.signal.core.util.censor
import org.thoughtcrime.securesms.database.RecipientTable.VibrateState
import org.thoughtcrime.securesms.recipients.Recipient
/**
* Represents everything that can happen on the custom notifications settings screen: the user's own actions, plus the
* screen coming back to the foreground and the recipient changing underneath us.
*/
sealed interface CustomNotificationsEvents {
/**
* The screen resumed, and our recipient row may no longer agree with the system notification channel.
*/
data object Foregrounded : CustomNotificationsEvents
/**
* The recipient we're displaying settings for was updated.
*/
data class RecipientChanged(val recipient: Recipient) : CustomNotificationsEvents {
override fun toString(): String = "RecipientChanged(recipient=${recipient.id})"
}
/**
* User toggled whether this recipient gets its own notification channel.
*/
data class SetHasCustomNotifications(val enabled: Boolean) : CustomNotificationsEvents
/**
* User picked a new sound for messages.
*
* @param uri The chosen sound, or null for silence.
*/
data class SetMessageSound(val uri: Uri?) : CustomNotificationsEvents {
override fun toString(): String = "SetMessageSound(uri=${uri.toString().censor()})"
}
/**
* User changed whether messages vibrate.
*/
data class SetMessageVibrate(val vibrateState: VibrateState) : CustomNotificationsEvents
/**
* User picked a new ringtone for calls.
*
* @param uri The chosen ringtone, or null for silence.
*/
data class SetCallSound(val uri: Uri?) : CustomNotificationsEvents {
override fun toString(): String = "SetCallSound(uri=${uri.toString().censor()})"
}
/**
* User changed whether calls vibrate.
*/
data class SetCallVibrate(val vibrateState: VibrateState) : CustomNotificationsEvents
/**
* User tapped the message sound row and wants to pick a new one.
*/
data object SelectMessageSound : CustomNotificationsEvents
/**
* User tapped the call ringtone row and wants to pick a new one.
*/
data object SelectCallSound : CustomNotificationsEvents
}
@@ -1,193 +1,31 @@
package org.thoughtcrime.securesms.components.settings.conversation.sounds.custom
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.media.RingtoneManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.viewModels
import org.signal.core.util.getParcelableExtraCompat
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.notifications.NotificationChannels
import org.thoughtcrime.securesms.util.ConversationUtil
import org.thoughtcrime.securesms.util.RingtoneUtil
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.signal.core.ui.compose.ComposeFragment
import org.thoughtcrime.securesms.util.viewModel
private val TAG = Log.tag(CustomNotificationsSettingsFragment::class.java)
/**
* Fragment wrapping [CustomNotificationsSettingsScreen] to allow user to set custom notifications for a given recipient.
*/
class CustomNotificationsSettingsFragment : ComposeFragment() {
class CustomNotificationsSettingsFragment : DSLSettingsFragment(R.string.CustomNotificationsDialogFragment__custom_notifications) {
private val vibrateLabels: Array<String> by lazy {
resources.getStringArray(R.array.recipient_vibrate_entries)
private val viewModel: CustomNotificationsSettingsViewModel by viewModel {
CustomNotificationsSettingsViewModel(CustomNotificationsSettingsFragmentArgs.fromBundle(requireArguments()).recipientId)
}
private val viewModel: CustomNotificationsSettingsViewModel by viewModels(factoryProducer = this::createFactory)
@Composable
override fun FragmentContent() {
val state by viewModel.state.collectAsStateWithLifecycle()
private lateinit var callSoundResultLauncher: ActivityResultLauncher<Intent>
private lateinit var messageSoundResultLauncher: ActivityResultLauncher<Intent>
private fun createFactory(): CustomNotificationsSettingsViewModel.Factory {
val recipientId = CustomNotificationsSettingsFragmentArgs.fromBundle(requireArguments()).recipientId
val repository = CustomNotificationsSettingsRepository(requireContext())
return CustomNotificationsSettingsViewModel.Factory(recipientId, repository)
}
override fun onResume() {
super.onResume()
viewModel.channelConsistencyCheck()
}
override fun bindAdapter(adapter: MappingAdapter) {
messageSoundResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
handleResult(result, viewModel::setMessageSound)
}
callSoundResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
handleResult(result, viewModel::setCallSound)
}
viewModel.state.observe(viewLifecycleOwner) { state ->
adapter.submitList(getConfiguration(state).toMappingModelList())
}
}
private fun handleResult(result: ActivityResult, resultHandler: (Uri?) -> Unit) {
val resultCode = result.resultCode
val data = result.data
if (resultCode == Activity.RESULT_OK && data != null) {
val uri: Uri? = data.getParcelableExtraCompat(RingtoneManager.EXTRA_RINGTONE_PICKED_URI, Uri::class.java)
resultHandler(uri)
}
}
private fun getConfiguration(state: CustomNotificationsSettingsState): DSLConfiguration {
return configure {
sectionHeaderPref(R.string.CustomNotificationsDialogFragment__messages)
if (NotificationChannels.supported()) {
switchPref(
title = DSLSettingsText.from(R.string.CustomNotificationsDialogFragment__use_custom_notifications),
isEnabled = state.isInitialLoadComplete,
isChecked = state.hasCustomNotifications,
onClick = { viewModel.setHasCustomNotifications(!state.hasCustomNotifications) }
)
CustomNotificationsSettingsScreen(
state = state,
ringtonePickerRequests = viewModel.ringtonePickerRequests,
onEvent = viewModel::onEvent,
onNavigationClick = {
requireActivity().onBackPressedDispatcher.onBackPressed()
}
if (Build.VERSION.SDK_INT >= 30) {
clickPref(
title = DSLSettingsText.from(R.string.CustomNotificationsDialogFragment__customize),
summary = DSLSettingsText.from(R.string.CustomNotificationsDialogFragment__change_sound_and_vibration),
isEnabled = state.controlsEnabled,
onClick = { NotificationChannels.getInstance().openChannelSettings(requireActivity(), state.recipient!!.notificationChannel!!, ConversationUtil.getShortcutId(state.recipient)) }
)
} else {
clickPref(
title = DSLSettingsText.from(R.string.CustomNotificationsDialogFragment__notification_sound),
summary = DSLSettingsText.from(getRingtoneSummary(requireContext(), state.messageSound, Settings.System.DEFAULT_NOTIFICATION_URI)),
isEnabled = state.controlsEnabled,
onClick = { requestSound(state.messageSound, false) }
)
if (NotificationChannels.supported()) {
switchPref(
title = DSLSettingsText.from(R.string.CustomNotificationsDialogFragment__vibrate),
isEnabled = state.controlsEnabled,
isChecked = state.messageVibrateEnabled,
onClick = { viewModel.setMessageVibrate(RecipientTable.VibrateState.fromBoolean(!state.messageVibrateEnabled)) }
)
} else {
radioListPref(
title = DSLSettingsText.from(R.string.CustomNotificationsDialogFragment__vibrate),
isEnabled = state.controlsEnabled,
listItems = vibrateLabels,
selected = state.messageVibrateState.id,
onSelected = {
viewModel.setMessageVibrate(RecipientTable.VibrateState.fromId(it))
}
)
}
}
if (state.showCallingOptions) {
dividerPref()
sectionHeaderPref(R.string.CustomNotificationsDialogFragment__call_settings)
clickPref(
title = DSLSettingsText.from(R.string.CustomNotificationsDialogFragment__ringtone),
summary = DSLSettingsText.from(getRingtoneSummary(requireContext(), state.callSound, Settings.System.DEFAULT_RINGTONE_URI)),
isEnabled = state.controlsEnabled,
onClick = { requestSound(state.callSound, true) }
)
radioListPref(
title = DSLSettingsText.from(R.string.CustomNotificationsDialogFragment__vibrate),
isEnabled = state.controlsEnabled,
listItems = vibrateLabels,
selected = state.callVibrateState.id,
onSelected = {
viewModel.setCallVibrate(RecipientTable.VibrateState.fromId(it))
}
)
}
}
)
}
private fun getRingtoneSummary(context: Context, ringtone: Uri?, defaultNotificationUri: Uri?): String {
if (ringtone == null || ringtone == defaultNotificationUri) {
return context.getString(R.string.CustomNotificationsDialogFragment__default)
} else if (ringtone.toString().isEmpty()) {
return context.getString(R.string.preferences__silent)
} else {
val tone = RingtoneUtil.getRingtone(requireContext(), ringtone)
if (tone != null) {
return try {
tone.getTitle(context)
} catch (e: NullPointerException) {
Log.w(TAG, "Could not get correct title for ringtone.", e)
context.getString(R.string.CustomNotificationsDialogFragment__unknown)
} catch (e: SecurityException) {
Log.w(TAG, "Could not get correct title for ringtone.", e)
context.getString(R.string.CustomNotificationsDialogFragment__unknown)
}
}
}
return context.getString(R.string.CustomNotificationsDialogFragment__default)
}
private fun requestSound(current: Uri?, forCalls: Boolean) {
val existing: Uri? = when {
current == null -> getDefaultSound(forCalls)
current.toString().isEmpty() -> null
else -> current
}
val intent = Intent(RingtoneManager.ACTION_RINGTONE_PICKER).apply {
putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, true)
putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true)
putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, if (forCalls) RingtoneManager.TYPE_RINGTONE else RingtoneManager.TYPE_NOTIFICATION)
putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, existing)
}
if (forCalls) {
callSoundResultLauncher.launch(intent)
} else {
messageSoundResultLauncher.launch(intent)
}
}
private fun getDefaultSound(forCalls: Boolean) = if (forCalls) Settings.System.DEFAULT_RINGTONE_URI else Settings.System.DEFAULT_NOTIFICATION_URI
}
@@ -1,10 +1,11 @@
package org.thoughtcrime.securesms.components.settings.conversation.sounds.custom
import android.content.Context
import android.net.Uri
import androidx.annotation.WorkerThread
import org.signal.core.util.concurrent.SerialExecutor
import org.signal.core.util.concurrent.SignalExecutors
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.signal.core.util.concurrent.SignalDispatchers
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -12,71 +13,69 @@ import org.thoughtcrime.securesms.notifications.NotificationChannels
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
class CustomNotificationsSettingsRepository(context: Context) {
/**
* All of the storage and notification channel access behind [CustomNotificationsSettingsViewModel].
*
* Channels and the recipient rows that point at them have to stay in sync, so every write here runs one at a time.
*/
object CustomNotificationsSettingsRepository {
private val context = context.applicationContext
private val executor = SerialExecutor(SignalExecutors.BOUNDED)
private val mutex = Mutex()
fun ensureCustomChannelConsistency(recipientId: RecipientId, onComplete: () -> Unit) {
executor.execute {
if (NotificationChannels.supported()) {
NotificationChannels.getInstance().ensureCustomChannelConsistency()
suspend fun ensureCustomChannelConsistency(recipientId: RecipientId) = serialized {
if (NotificationChannels.supported()) {
NotificationChannels.getInstance().ensureCustomChannelConsistency()
val recipient = Recipient.resolved(recipientId)
val database = SignalDatabase.recipients
if (recipient.notificationChannel != null) {
val ringtoneUri: Uri? = NotificationChannels.getInstance().getMessageRingtone(recipient)
database.setMessageRingtone(recipient.id, if (ringtoneUri == Uri.EMPTY) null else ringtoneUri)
database.setMessageVibrate(recipient.id, RecipientTable.VibrateState.fromBoolean(NotificationChannels.getInstance().getMessageVibrate(recipient)))
}
}
onComplete()
}
}
fun setHasCustomNotifications(recipientId: RecipientId, hasCustomNotifications: Boolean) {
executor.execute {
if (hasCustomNotifications) {
createCustomNotificationChannel(recipientId)
} else {
deleteCustomNotificationChannel(recipientId)
val recipient = Recipient.resolved(recipientId)
val database = SignalDatabase.recipients
if (recipient.notificationChannel != null) {
val ringtoneUri: Uri? = NotificationChannels.getInstance().getMessageRingtone(recipient)
database.setMessageRingtone(recipient.id, if (ringtoneUri == Uri.EMPTY) null else ringtoneUri)
database.setMessageVibrate(recipient.id, RecipientTable.VibrateState.fromBoolean(NotificationChannels.getInstance().getMessageVibrate(recipient)))
}
}
}
fun setMessageVibrate(recipientId: RecipientId, vibrateState: RecipientTable.VibrateState) {
executor.execute {
val recipient: Recipient = Recipient.resolved(recipientId)
SignalDatabase.recipients.setMessageVibrate(recipient.id, vibrateState)
NotificationChannels.getInstance().updateMessageVibrate(recipient, vibrateState)
suspend fun setHasCustomNotifications(recipientId: RecipientId, hasCustomNotifications: Boolean) = serialized {
if (hasCustomNotifications) {
createCustomNotificationChannel(recipientId)
} else {
deleteCustomNotificationChannel(recipientId)
}
}
fun setCallingVibrate(recipientId: RecipientId, vibrateState: RecipientTable.VibrateState) {
executor.execute {
SignalDatabase.recipients.setCallVibrate(recipientId, vibrateState)
}
suspend fun setMessageVibrate(recipientId: RecipientId, vibrateState: RecipientTable.VibrateState) = serialized {
val recipient: Recipient = Recipient.resolved(recipientId)
SignalDatabase.recipients.setMessageVibrate(recipient.id, vibrateState)
NotificationChannels.getInstance().updateMessageVibrate(recipient, vibrateState)
}
fun setMessageSound(recipientId: RecipientId, sound: Uri?) {
executor.execute {
val recipient: Recipient = Recipient.resolved(recipientId)
val defaultValue = SignalStore.settings.messageNotificationSound
val newValue: Uri? = if (defaultValue == sound) null else sound ?: Uri.EMPTY
SignalDatabase.recipients.setMessageRingtone(recipient.id, newValue)
NotificationChannels.getInstance().updateMessageRingtone(recipient, newValue)
}
suspend fun setCallingVibrate(recipientId: RecipientId, vibrateState: RecipientTable.VibrateState) = serialized {
SignalDatabase.recipients.setCallVibrate(recipientId, vibrateState)
}
fun setCallSound(recipientId: RecipientId, sound: Uri?) {
executor.execute {
val defaultValue = SignalStore.settings.callRingtone
val newValue: Uri? = if (defaultValue == sound) null else sound ?: Uri.EMPTY
suspend fun setMessageSound(recipientId: RecipientId, sound: Uri?) = serialized {
val recipient: Recipient = Recipient.resolved(recipientId)
val defaultValue = SignalStore.settings.messageNotificationSound
val newValue: Uri? = if (defaultValue == sound) null else sound ?: Uri.EMPTY
SignalDatabase.recipients.setCallRingtone(recipientId, newValue)
SignalDatabase.recipients.setMessageRingtone(recipient.id, newValue)
NotificationChannels.getInstance().updateMessageRingtone(recipient, newValue)
}
suspend fun setCallSound(recipientId: RecipientId, sound: Uri?) = serialized {
val defaultValue = SignalStore.settings.callRingtone
val newValue: Uri? = if (defaultValue == sound) null else sound ?: Uri.EMPTY
SignalDatabase.recipients.setCallRingtone(recipientId, newValue)
}
private suspend fun <T> serialized(block: () -> T): T {
return mutex.withLock {
withContext(SignalDispatchers.Default) {
block()
}
}
}
@@ -0,0 +1,306 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.conversation.sounds.custom
import android.app.Activity
import android.content.Intent
import android.media.RingtoneManager
import android.net.Uri
import android.provider.Settings
import androidx.activity.compose.LocalActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import org.signal.core.ui.compose.DayNightPreviews
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.util.getParcelableExtraCompat
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.database.RecipientTable.VibrateState
import org.thoughtcrime.securesms.notifications.NotificationChannels
import org.thoughtcrime.securesms.util.ConversationUtil
import org.thoughtcrime.securesms.util.RingtoneUtil
private const val TAG = "CustomNotificationsScreen"
/**
* Per-recipient notification sound and vibration settings.
*
* @param ringtonePickerRequests Each request opens the system ringtone picker, whose result comes back as a
* [CustomNotificationsEvents.SetMessageSound] or [CustomNotificationsEvents.SetCallSound].
*/
@Composable
fun CustomNotificationsSettingsScreen(
state: CustomNotificationsSettingsState,
ringtonePickerRequests: Flow<RingtonePickerRequest>,
onEvent: (CustomNotificationsEvents) -> Unit,
onNavigationClick: () -> Unit
) {
val activity = LocalActivity.current
val vibrateLabels = stringArrayResource(R.array.recipient_vibrate_entries)
val vibrateValues = remember { VibrateState.entries.map { it.id.toString() }.toTypedArray() }
val messageSoundLauncher = rememberRingtonePickerLauncher { onEvent(CustomNotificationsEvents.SetMessageSound(it)) }
val callSoundLauncher = rememberRingtonePickerLauncher { onEvent(CustomNotificationsEvents.SetCallSound(it)) }
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
onEvent(CustomNotificationsEvents.Foregrounded)
}
LaunchedEffect(ringtonePickerRequests, messageSoundLauncher, callSoundLauncher) {
ringtonePickerRequests.collect { request ->
when (request.target) {
RingtonePickerRequest.Target.MESSAGE -> messageSoundLauncher.launch(request.toIntent())
RingtonePickerRequest.Target.CALL -> callSoundLauncher.launch(request.toIntent())
}
}
}
Scaffolds.Settings(
title = stringResource(R.string.CustomNotificationsDialogFragment__custom_notifications),
onNavigationClick = onNavigationClick,
navigationIcon = SignalIcons.ArrowStart.imageVector,
navigationContentDescription = stringResource(R.string.CallScreenTopBar__go_back)
) { paddingValues ->
LazyColumn(
modifier = Modifier
.padding(paddingValues)
.testTag(CustomNotificationsTestTags.CONTENT)
) {
item {
Texts.SectionHeader(text = stringResource(R.string.CustomNotificationsDialogFragment__messages))
}
if (state.supportsNotificationChannels) {
item {
Rows.ToggleRow(
checked = state.hasCustomNotifications,
text = stringResource(R.string.CustomNotificationsDialogFragment__use_custom_notifications),
onCheckChanged = { onEvent(CustomNotificationsEvents.SetHasCustomNotifications(it)) },
modifier = Modifier.testTag(CustomNotificationsTestTags.CUSTOM_NOTIFICATIONS_TOGGLE),
enabled = state.isInitialLoadComplete
)
}
}
if (state.canOpenChannelSettings) {
item {
Rows.TextRow(
modifier = Modifier.testTag(CustomNotificationsTestTags.CUSTOMIZE_ROW),
text = stringResource(R.string.CustomNotificationsDialogFragment__customize),
label = stringResource(R.string.CustomNotificationsDialogFragment__change_sound_and_vibration),
enabled = state.controlsEnabled,
onClick = {
val notificationChannel = state.notificationChannel
if (activity != null && notificationChannel != null) {
NotificationChannels.getInstance().openChannelSettings(activity, notificationChannel, ConversationUtil.getShortcutId(state.recipientId))
}
}
)
}
} else {
item {
Rows.TextRow(
modifier = Modifier.testTag(CustomNotificationsTestTags.MESSAGE_SOUND_ROW),
text = stringResource(R.string.CustomNotificationsDialogFragment__notification_sound),
label = rememberRingtoneSummary(state.messageSound, Settings.System.DEFAULT_NOTIFICATION_URI),
enabled = state.controlsEnabled,
onClick = { onEvent(CustomNotificationsEvents.SelectMessageSound) }
)
}
if (state.supportsNotificationChannels) {
item {
Rows.ToggleRow(
checked = state.messageVibrateEnabled,
text = stringResource(R.string.CustomNotificationsDialogFragment__vibrate),
onCheckChanged = { onEvent(CustomNotificationsEvents.SetMessageVibrate(VibrateState.fromBoolean(it))) },
modifier = Modifier.testTag(CustomNotificationsTestTags.MESSAGE_VIBRATE_TOGGLE),
enabled = state.controlsEnabled
)
}
} else {
item {
Rows.RadioListRow(
text = stringResource(R.string.CustomNotificationsDialogFragment__vibrate),
labels = vibrateLabels,
values = vibrateValues,
selectedValue = state.messageVibrateState.id.toString(),
onSelected = { onEvent(CustomNotificationsEvents.SetMessageVibrate(VibrateState.fromId(it.toInt()))) },
modifier = Modifier.testTag(CustomNotificationsTestTags.MESSAGE_VIBRATE_ROW),
enabled = state.controlsEnabled
)
}
}
}
if (state.showCallingOptions) {
item {
Dividers.Default()
}
item {
Texts.SectionHeader(text = stringResource(R.string.CustomNotificationsDialogFragment__call_settings))
}
item {
Rows.TextRow(
modifier = Modifier.testTag(CustomNotificationsTestTags.CALL_SOUND_ROW),
text = stringResource(R.string.CustomNotificationsDialogFragment__ringtone),
label = rememberRingtoneSummary(state.callSound, Settings.System.DEFAULT_RINGTONE_URI),
enabled = state.controlsEnabled,
onClick = { onEvent(CustomNotificationsEvents.SelectCallSound) }
)
}
item {
Rows.RadioListRow(
text = stringResource(R.string.CustomNotificationsDialogFragment__vibrate),
labels = vibrateLabels,
values = vibrateValues,
selectedValue = state.callVibrateState.id.toString(),
onSelected = { onEvent(CustomNotificationsEvents.SetCallVibrate(VibrateState.fromId(it.toInt()))) },
modifier = Modifier.testTag(CustomNotificationsTestTags.CALL_VIBRATE_ROW),
enabled = state.controlsEnabled
)
}
}
}
}
}
@Composable
private fun rememberRingtonePickerLauncher(onPicked: (Uri?) -> Unit): ActivityResultLauncher<Intent> {
return rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val data = result.data
if (result.resultCode == Activity.RESULT_OK && data != null) {
onPicked(data.getParcelableExtraCompat(RingtoneManager.EXTRA_RINGTONE_PICKED_URI, Uri::class.java))
}
}
}
private fun RingtonePickerRequest.toIntent(): Intent {
return Intent(RingtoneManager.ACTION_RINGTONE_PICKER).apply {
putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, true)
putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true)
putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, if (target == RingtonePickerRequest.Target.CALL) RingtoneManager.TYPE_RINGTONE else RingtoneManager.TYPE_NOTIFICATION)
putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, existing)
}
}
/**
* Display name for a notification sound, falling back to the name of the system default when we can't resolve it.
*
* Resolving a sound hits the media store, so the result is remembered until the sound itself changes.
*/
@Composable
private fun rememberRingtoneSummary(ringtone: Uri?, defaultUri: Uri?): String {
val context = LocalContext.current
val defaultSummary = stringResource(R.string.CustomNotificationsDialogFragment__default)
val silentSummary = stringResource(R.string.preferences__silent)
val unknownSummary = stringResource(R.string.CustomNotificationsDialogFragment__unknown)
return remember(context, ringtone, defaultUri, defaultSummary, silentSummary, unknownSummary) {
if (ringtone == null || ringtone == defaultUri) {
return@remember defaultSummary
}
if (ringtone.toString().isEmpty()) {
return@remember silentSummary
}
val tone = RingtoneUtil.getRingtone(context, ringtone) ?: return@remember defaultSummary
try {
tone.getTitle(context)
} catch (e: NullPointerException) {
Log.w(TAG, "Could not get correct title for ringtone.", e)
unknownSummary
} catch (e: SecurityException) {
Log.w(TAG, "Could not get correct title for ringtone.", e)
unknownSummary
}
}
}
@DayNightPreviews
@Composable
private fun CustomNotificationsSettingsScreenChannelSettingsPreview() {
Previews.Preview {
CustomNotificationsSettingsScreen(
state = CustomNotificationsSettingsState(
isInitialLoadComplete = true,
supportsNotificationChannels = true,
canOpenChannelSettings = true,
notificationChannel = "channel",
showCallingOptions = true
),
ringtonePickerRequests = emptyFlow(),
onEvent = {},
onNavigationClick = {}
)
}
}
@DayNightPreviews
@Composable
private fun CustomNotificationsSettingsScreenInAppSettingsPreview() {
Previews.Preview {
CustomNotificationsSettingsScreen(
state = CustomNotificationsSettingsState(
isInitialLoadComplete = true,
supportsNotificationChannels = true,
canOpenChannelSettings = false,
notificationChannel = "channel",
messageVibrateEnabled = true,
showCallingOptions = true
),
ringtonePickerRequests = emptyFlow(),
onEvent = {},
onNavigationClick = {}
)
}
}
@DayNightPreviews
@Composable
private fun CustomNotificationsSettingsScreenWithoutChannelsPreview() {
Previews.Preview {
CustomNotificationsSettingsScreen(
state = CustomNotificationsSettingsState(
isInitialLoadComplete = true,
supportsNotificationChannels = false,
canOpenChannelSettings = false,
messageVibrateState = VibrateState.ENABLED,
callVibrateState = VibrateState.DISABLED,
showCallingOptions = true
),
ringtonePickerRequests = emptyFlow(),
onEvent = {},
onNavigationClick = {}
)
}
}
@@ -1,18 +1,24 @@
package org.thoughtcrime.securesms.components.settings.conversation.sounds.custom
import android.net.Uri
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.database.RecipientTable.VibrateState
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
data class CustomNotificationsSettingsState(
val recipientId: RecipientId = Recipient.UNKNOWN.id,
val isInitialLoadComplete: Boolean = false,
val recipient: Recipient? = null,
val hasCustomNotifications: Boolean = false,
val controlsEnabled: Boolean = false,
val messageVibrateState: RecipientTable.VibrateState = RecipientTable.VibrateState.DEFAULT,
val supportsNotificationChannels: Boolean = false,
val canOpenChannelSettings: Boolean = false,
val notificationChannel: String? = null,
val messageVibrateState: VibrateState = VibrateState.DEFAULT,
val messageVibrateEnabled: Boolean = false,
val messageSound: Uri? = null,
val callVibrateState: RecipientTable.VibrateState = RecipientTable.VibrateState.DEFAULT,
val callVibrateState: VibrateState = VibrateState.DEFAULT,
val callSound: Uri? = null,
val showCallingOptions: Boolean = false
)
) {
val hasCustomNotifications: Boolean = supportsNotificationChannels && notificationChannel != null
val controlsEnabled: Boolean = isInitialLoadComplete && (!supportsNotificationChannels || hasCustomNotifications)
}
@@ -1,38 +1,94 @@
package org.thoughtcrime.securesms.components.settings.conversation.sounds.custom
import android.net.Uri
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import org.thoughtcrime.securesms.database.RecipientTable
import android.os.Build
import android.provider.Settings
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import org.signal.core.ui.compose.EventDrivenViewModel
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.database.RecipientTable.VibrateState
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.notifications.NotificationChannels
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.livedata.Store
class CustomNotificationsSettingsViewModel(
private val recipientId: RecipientId,
private val repository: CustomNotificationsSettingsRepository
) : ViewModel() {
private val recipientId: RecipientId
) : EventDrivenViewModel<CustomNotificationsEvents>(TAG), RecipientForeverObserver {
private val store = Store(CustomNotificationsSettingsState())
companion object {
private val TAG = Log.tag(CustomNotificationsSettingsViewModel::class)
}
val state: LiveData<CustomNotificationsSettingsState> = store.stateLiveData
private val _state = MutableStateFlow(
CustomNotificationsSettingsState(
recipientId = recipientId,
supportsNotificationChannels = NotificationChannels.supported(),
canOpenChannelSettings = Build.VERSION.SDK_INT >= 30
)
)
val state: StateFlow<CustomNotificationsSettingsState> = _state
private val internalRingtonePickerRequests: Channel<RingtonePickerRequest> = Channel(Channel.BUFFERED)
val ringtonePickerRequests: Flow<RingtonePickerRequest> = internalRingtonePickerRequests.receiveAsFlow()
private val liveRecipient = Recipient.live(recipientId)
init {
store.update(Recipient.live(recipientId).liveData) { recipient, state ->
val recipientHasCustomNotifications = NotificationChannels.supported() && recipient.notificationChannel != null
state.copy(
recipient = recipient,
hasCustomNotifications = recipientHasCustomNotifications,
controlsEnabled = (!NotificationChannels.supported() || recipientHasCustomNotifications) && state.isInitialLoadComplete,
liveRecipient.observeForever(this)
onRecipientChanged(liveRecipient.get())
}
override fun onRecipientChanged(recipient: Recipient) {
onEvent(CustomNotificationsEvents.RecipientChanged(recipient))
}
override fun onCleared() {
liveRecipient.removeForeverObserver(this)
}
override suspend fun processEvent(event: CustomNotificationsEvents) {
when (event) {
CustomNotificationsEvents.Foregrounded -> applyForegroundedEvent()
is CustomNotificationsEvents.RecipientChanged -> applyRecipientChangedEvent(event.recipient)
is CustomNotificationsEvents.SetHasCustomNotifications -> CustomNotificationsSettingsRepository.setHasCustomNotifications(recipientId, event.enabled)
is CustomNotificationsEvents.SetMessageSound -> CustomNotificationsSettingsRepository.setMessageSound(recipientId, event.uri)
is CustomNotificationsEvents.SetMessageVibrate -> CustomNotificationsSettingsRepository.setMessageVibrate(recipientId, event.vibrateState)
is CustomNotificationsEvents.SetCallSound -> CustomNotificationsSettingsRepository.setCallSound(recipientId, event.uri)
is CustomNotificationsEvents.SetCallVibrate -> CustomNotificationsSettingsRepository.setCallingVibrate(recipientId, event.vibrateState)
CustomNotificationsEvents.SelectMessageSound -> requestSound(RingtonePickerRequest.Target.MESSAGE, _state.value.messageSound)
CustomNotificationsEvents.SelectCallSound -> requestSound(RingtonePickerRequest.Target.CALL, _state.value.callSound)
}
}
/**
* Re-syncs our recipient row with the system notification channel, which the user may have edited outside the app.
* Controls stay disabled until it finishes.
*/
private suspend fun applyForegroundedEvent() {
_state.update { it.copy(isInitialLoadComplete = false) }
CustomNotificationsSettingsRepository.ensureCustomChannelConsistency(recipientId)
_state.update { it.copy(isInitialLoadComplete = true) }
}
private fun applyRecipientChangedEvent(recipient: Recipient) {
_state.update {
it.copy(
notificationChannel = recipient.notificationChannel,
messageSound = recipient.messageRingtone,
messageVibrateState = recipient.messageVibrate,
messageVibrateEnabled = when (recipient.messageVibrate) {
RecipientTable.VibrateState.DEFAULT -> SignalStore.settings.isMessageVibrateEnabled
RecipientTable.VibrateState.ENABLED -> true
RecipientTable.VibrateState.DISABLED -> false
VibrateState.DEFAULT -> SignalStore.settings.isMessageVibrateEnabled
VibrateState.ENABLED -> true
VibrateState.DISABLED -> false
},
showCallingOptions = recipient.isRegistered,
callSound = recipient.callRingtone,
@@ -41,44 +97,13 @@ class CustomNotificationsSettingsViewModel(
}
}
fun setHasCustomNotifications(hasCustomNotifications: Boolean) {
repository.setHasCustomNotifications(recipientId, hasCustomNotifications)
}
fun setMessageVibrate(messageVibrateState: RecipientTable.VibrateState) {
repository.setMessageVibrate(recipientId, messageVibrateState)
}
fun setMessageSound(uri: Uri?) {
repository.setMessageSound(recipientId, uri)
}
fun setCallVibrate(callVibrateState: RecipientTable.VibrateState) {
repository.setCallingVibrate(recipientId, callVibrateState)
}
fun setCallSound(uri: Uri?) {
repository.setCallSound(recipientId, uri)
}
fun channelConsistencyCheck() {
store.update { it.copy(isInitialLoadComplete = false) }
repository.ensureCustomChannelConsistency(recipientId) {
store.update {
it.copy(
isInitialLoadComplete = true,
controlsEnabled = (!NotificationChannels.supported() || it.hasCustomNotifications)
)
}
private fun requestSound(target: RingtonePickerRequest.Target, current: Uri?) {
val existing: Uri? = when {
current == null -> if (target == RingtonePickerRequest.Target.CALL) Settings.System.DEFAULT_RINGTONE_URI else Settings.System.DEFAULT_NOTIFICATION_URI
current.toString().isEmpty() -> null
else -> current
}
}
class Factory(
private val recipientId: RecipientId,
private val repository: CustomNotificationsSettingsRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(CustomNotificationsSettingsViewModel(recipientId, repository)))
}
internalRingtonePickerRequests.trySend(RingtonePickerRequest(target, existing))
}
}
@@ -0,0 +1,20 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.conversation.sounds.custom
object CustomNotificationsTestTags {
const val CONTENT = "content"
const val CUSTOM_NOTIFICATIONS_TOGGLE = "custom_notifications_toggle"
const val CUSTOMIZE_ROW = "customize_row"
const val MESSAGE_SOUND_ROW = "message_sound_row"
const val MESSAGE_VIBRATE_TOGGLE = "message_vibrate_toggle"
const val MESSAGE_VIBRATE_ROW = "message_vibrate_row"
const val CALL_SOUND_ROW = "call_sound_row"
const val CALL_VIBRATE_ROW = "call_vibrate_row"
}
@@ -0,0 +1,24 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.conversation.sounds.custom
import android.net.Uri
/**
* A request from [CustomNotificationsSettingsViewModel] to open the system ringtone picker.
*
* @param target Which sound the user is choosing, and therefore which kind of picker to open.
* @param existing The sound the picker should open on, or null for silence.
*/
data class RingtonePickerRequest(
val target: Target,
val existing: Uri?
) {
enum class Target {
MESSAGE,
CALL
}
}
@@ -0,0 +1,213 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.conversation.sounds.custom
import android.app.Application
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToNode
import androidx.test.core.app.ApplicationProvider
import assertk.assertThat
import assertk.assertions.contains
import assertk.assertions.isEmpty
import kotlinx.coroutines.flow.emptyFlow
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.signal.core.ui.CoreUiDependenciesRule
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.theme.SignalTheme
import org.thoughtcrime.securesms.database.RecipientTable.VibrateState
/**
* Checks which events the custom notifications rows emit, and which rows a given state renders at all.
*/
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class)
class CustomNotificationsSettingsScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@get:Rule
val coreUiDependenciesRule = CoreUiDependenciesRule(ApplicationProvider.getApplicationContext())
@Test
fun `resuming emits Foregrounded so the channel can be re-checked`() {
val events = setContent(createState())
assertThat(events).contains(CustomNotificationsEvents.Foregrounded)
}
@Test
fun `turning custom notifications on emits SetHasCustomNotifications`() {
val events = setContent(createState(notificationChannel = null))
click(CustomNotificationsTestTags.CUSTOM_NOTIFICATIONS_TOGGLE)
assertThat(events.userDriven()).contains(CustomNotificationsEvents.SetHasCustomNotifications(true))
}
@Test
fun `turning custom notifications off emits SetHasCustomNotifications`() {
val events = setContent(createState())
click(CustomNotificationsTestTags.CUSTOM_NOTIFICATIONS_TOGGLE)
assertThat(events.userDriven()).contains(CustomNotificationsEvents.SetHasCustomNotifications(false))
}
@Test
fun `the custom notifications toggle is hidden where channels are unsupported`() {
setContent(createState(supportsNotificationChannels = false))
composeTestRule.onNodeWithTag(CustomNotificationsTestTags.CUSTOM_NOTIFICATIONS_TOGGLE).assertDoesNotExist()
}
@Test
fun `no control does anything until the initial load completes`() {
val events = setContent(createState(isInitialLoadComplete = false))
click(CustomNotificationsTestTags.CUSTOM_NOTIFICATIONS_TOGGLE)
click(CustomNotificationsTestTags.MESSAGE_SOUND_ROW)
click(CustomNotificationsTestTags.CALL_SOUND_ROW)
assertThat(events.userDriven()).isEmpty()
}
@Test
fun `sound and vibration are handed to the system where channel settings can be opened`() {
setContent(createState(canOpenChannelSettings = true))
composeTestRule.onNodeWithTag(CustomNotificationsTestTags.CUSTOMIZE_ROW).assertIsDisplayed()
composeTestRule.onNodeWithTag(CustomNotificationsTestTags.MESSAGE_SOUND_ROW).assertDoesNotExist()
composeTestRule.onNodeWithTag(CustomNotificationsTestTags.MESSAGE_VIBRATE_TOGGLE).assertDoesNotExist()
}
@Test
fun `tapping the message sound row emits SelectMessageSound`() {
val events = setContent(createState())
click(CustomNotificationsTestTags.MESSAGE_SOUND_ROW)
assertThat(events.userDriven()).contains(CustomNotificationsEvents.SelectMessageSound)
}
@Test
fun `tapping the call ringtone row emits SelectCallSound`() {
val events = setContent(createState())
click(CustomNotificationsTestTags.CALL_SOUND_ROW)
assertThat(events.userDriven()).contains(CustomNotificationsEvents.SelectCallSound)
}
@Test
fun `the message sound row does nothing while custom notifications are off`() {
val events = setContent(createState(notificationChannel = null))
click(CustomNotificationsTestTags.MESSAGE_SOUND_ROW)
assertThat(events.userDriven()).isEmpty()
}
@Test
fun `toggling message vibration emits SetMessageVibrate`() {
val events = setContent(createState(messageVibrateEnabled = false))
click(CustomNotificationsTestTags.MESSAGE_VIBRATE_TOGGLE)
assertThat(events.userDriven()).contains(CustomNotificationsEvents.SetMessageVibrate(VibrateState.ENABLED))
}
@Test
fun `picking a message vibration option emits SetMessageVibrate`() {
val events = setContent(createState(supportsNotificationChannels = false))
click(CustomNotificationsTestTags.MESSAGE_VIBRATE_ROW)
selectVibrateOption(VibrateState.DISABLED)
assertThat(events.userDriven()).contains(CustomNotificationsEvents.SetMessageVibrate(VibrateState.DISABLED))
}
@Test
fun `picking a call vibration option emits SetCallVibrate`() {
val events = setContent(createState())
click(CustomNotificationsTestTags.CALL_VIBRATE_ROW)
selectVibrateOption(VibrateState.ENABLED)
assertThat(events.userDriven()).contains(CustomNotificationsEvents.SetCallVibrate(VibrateState.ENABLED))
}
@Test
fun `the call section is hidden for an unregistered recipient`() {
setContent(createState(showCallingOptions = false))
composeTestRule.onNodeWithTag(CustomNotificationsTestTags.CALL_SOUND_ROW).assertDoesNotExist()
composeTestRule.onNodeWithTag(CustomNotificationsTestTags.CALL_VIBRATE_ROW).assertDoesNotExist()
}
private fun createState(
isInitialLoadComplete: Boolean = true,
supportsNotificationChannels: Boolean = true,
canOpenChannelSettings: Boolean = false,
notificationChannel: String? = "channel",
messageVibrateEnabled: Boolean = false,
showCallingOptions: Boolean = true
): CustomNotificationsSettingsState {
return CustomNotificationsSettingsState(
isInitialLoadComplete = isInitialLoadComplete,
supportsNotificationChannels = supportsNotificationChannels,
canOpenChannelSettings = canOpenChannelSettings,
notificationChannel = notificationChannel,
messageVibrateEnabled = messageVibrateEnabled,
showCallingOptions = showCallingOptions
)
}
private fun setContent(state: CustomNotificationsSettingsState): List<CustomNotificationsEvents> {
val events = mutableListOf<CustomNotificationsEvents>()
composeTestRule.setContent {
SignalTheme {
CustomNotificationsSettingsScreen(
state = state,
ringtonePickerRequests = emptyFlow(),
onEvent = { events += it },
onNavigationClick = {}
)
}
}
return events
}
private fun click(tag: String) {
composeTestRule.onNodeWithTag(CustomNotificationsTestTags.CONTENT).performScrollToNode(hasTestTag(tag))
composeTestRule.onNodeWithTag(tag).performClick()
}
/**
* Picks an option out of an open vibrate dialog, whose options are ordered by [VibrateState.id].
*/
private fun selectVibrateOption(vibrateState: VibrateState) {
composeTestRule.onNodeWithTag(Dialogs.testTagRadioListDialogOption(vibrateState.id)).performClick()
}
/**
* Everything the user themselves caused, dropping the [CustomNotificationsEvents.Foregrounded] that the screen emits
* on its own as soon as it resumes.
*/
private fun List<CustomNotificationsEvents>.userDriven(): List<CustomNotificationsEvents> {
return filterNot { it == CustomNotificationsEvents.Foregrounded }
}
}
@@ -81,6 +81,11 @@ object Dialogs {
const val TEST_TAG_ADVANCED_ALERT_DIALOG_NEUTRAL_BUTTON = "dialog-advanced-neutral-button"
const val TEST_TAG_ADVANCED_ALERT_DIALOG_NEGATIVE_BUTTON = "dialog-advanced-negative-button"
/** Suffixed with the index of the option the row renders. */
const val TEST_TAG_RADIO_LIST_DIALOG_OPTION = "dialog-radio-list-option"
fun testTagRadioListDialogOption(index: Int) = "$TEST_TAG_RADIO_LIST_DIALOG_OPTION:$index"
object Defaults {
val shape: Shape @Composable get() = RoundedCornerShape(28.dp)
val containerColor: Color @Composable get() = SignalTheme.colors.colorSurface1
@@ -601,6 +606,7 @@ object Dialogs {
}
)
.horizontalGutters()
.testTag(testTagRadioListDialogOption(index))
) {
RadioButton(
enabled = true,
@@ -153,6 +153,7 @@ object Rows {
values: Array<String>,
selectedValue: String,
onSelected: (String) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true
) {
RadioListRow(
@@ -173,6 +174,7 @@ object Rows {
values = values,
selectedValue = selectedValue,
onSelected = onSelected,
modifier = modifier,
enabled = enabled
)
}
@@ -185,6 +187,7 @@ object Rows {
values: Array<String>,
selectedValue: String,
onSelected: (String) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true
) {
val selectedIndex = values.indexOf(selectedValue)
@@ -196,7 +199,7 @@ object Rows {
onClick = {
displayDialog = true
},
modifier = Modifier.alpha(if (enabled) 1f else DISABLED_ALPHA)
modifier = modifier.alpha(if (enabled) 1f else DISABLED_ALPHA)
)
if (displayDialog) {