mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-20 00:35:47 +01:00
Convert CustomNotificationSettings to compose.
This commit is contained in:
committed by
Cody Henthorne
parent
f4a22c5854
commit
832da0da4a
+73
@@ -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
|
||||
}
|
||||
+21
-183
@@ -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
|
||||
}
|
||||
|
||||
+52
-53
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+306
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
+13
-7
@@ -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)
|
||||
}
|
||||
|
||||
+81
-56
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+20
@@ -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"
|
||||
}
|
||||
+24
@@ -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
|
||||
}
|
||||
}
|
||||
+213
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user