diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/custom/CustomNotificationsEvents.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/custom/CustomNotificationsEvents.kt new file mode 100644 index 0000000000..c630ca1443 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/custom/CustomNotificationsEvents.kt @@ -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 +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/custom/CustomNotificationsSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/custom/CustomNotificationsSettingsFragment.kt index a03ebb4e6f..eaca4c6749 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/custom/CustomNotificationsSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/custom/CustomNotificationsSettingsFragment.kt @@ -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 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 - private lateinit var messageSoundResultLauncher: ActivityResultLauncher - - 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 } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/custom/CustomNotificationsSettingsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/custom/CustomNotificationsSettingsRepository.kt index 9b10221de9..a79a18f2e7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/custom/CustomNotificationsSettingsRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/custom/CustomNotificationsSettingsRepository.kt @@ -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 serialized(block: () -> T): T { + return mutex.withLock { + withContext(SignalDispatchers.Default) { + block() + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/custom/CustomNotificationsSettingsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/custom/CustomNotificationsSettingsScreen.kt new file mode 100644 index 0000000000..da5ef789ee --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/custom/CustomNotificationsSettingsScreen.kt @@ -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, + 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 { + 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 = {} + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/custom/CustomNotificationsSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/custom/CustomNotificationsSettingsState.kt index 312e0dbb3e..52ea416a86 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/custom/CustomNotificationsSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/custom/CustomNotificationsSettingsState.kt @@ -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) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/custom/CustomNotificationsSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/custom/CustomNotificationsSettingsViewModel.kt index c3952e7da6..05a64fc39d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/custom/CustomNotificationsSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/custom/CustomNotificationsSettingsViewModel.kt @@ -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(TAG), RecipientForeverObserver { - private val store = Store(CustomNotificationsSettingsState()) + companion object { + private val TAG = Log.tag(CustomNotificationsSettingsViewModel::class) + } - val state: LiveData = store.stateLiveData + private val _state = MutableStateFlow( + CustomNotificationsSettingsState( + recipientId = recipientId, + supportsNotificationChannels = NotificationChannels.supported(), + canOpenChannelSettings = Build.VERSION.SDK_INT >= 30 + ) + ) + val state: StateFlow = _state + + private val internalRingtonePickerRequests: Channel = Channel(Channel.BUFFERED) + val ringtonePickerRequests: Flow = 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 create(modelClass: Class): T { - return requireNotNull(modelClass.cast(CustomNotificationsSettingsViewModel(recipientId, repository))) - } + internalRingtonePickerRequests.trySend(RingtonePickerRequest(target, existing)) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/custom/CustomNotificationsTestTags.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/custom/CustomNotificationsTestTags.kt new file mode 100644 index 0000000000..dba9fab86b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/custom/CustomNotificationsTestTags.kt @@ -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" +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/custom/RingtonePickerRequest.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/custom/RingtonePickerRequest.kt new file mode 100644 index 0000000000..fd9317bca9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/custom/RingtonePickerRequest.kt @@ -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 + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/custom/CustomNotificationsSettingsScreenTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/custom/CustomNotificationsSettingsScreenTest.kt new file mode 100644 index 0000000000..e3304a8b21 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/sounds/custom/CustomNotificationsSettingsScreenTest.kt @@ -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 { + val events = mutableListOf() + + 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.userDriven(): List { + return filterNot { it == CustomNotificationsEvents.Foregrounded } + } +} diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/Dialogs.kt b/core/ui/src/main/java/org/signal/core/ui/compose/Dialogs.kt index 5d288e6291..065b01903d 100644 --- a/core/ui/src/main/java/org/signal/core/ui/compose/Dialogs.kt +++ b/core/ui/src/main/java/org/signal/core/ui/compose/Dialogs.kt @@ -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, diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/Rows.kt b/core/ui/src/main/java/org/signal/core/ui/compose/Rows.kt index 61520bc177..cf731f3f82 100644 --- a/core/ui/src/main/java/org/signal/core/ui/compose/Rows.kt +++ b/core/ui/src/main/java/org/signal/core/ui/compose/Rows.kt @@ -153,6 +153,7 @@ object Rows { values: Array, 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, 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) {