Convert NotificationsSettingsFragment to compose.

This commit is contained in:
Alex Hart
2025-09-10 15:12:54 -03:00
committed by Greyson Parrelli
parent 7af811eb3f
commit 2eb4f650d8
8 changed files with 778 additions and 377 deletions

View File

@@ -0,0 +1,32 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.notifications
import android.content.Context
import android.content.Intent
import android.provider.Settings
import androidx.activity.result.contract.ActivityResultContract
import org.thoughtcrime.securesms.notifications.NotificationChannels
/**
* Activity result contract for launching the system notification channel settings screen
* for the messages notification channel.
*
* This contract allows users to configure notification priority, sound, vibration, and other
* channel-specific settings through the system's native notification settings UI.
*/
class NotificationPrioritySelectionContract : ActivityResultContract<Unit, Unit>() {
override fun createIntent(context: Context, input: Unit): Intent {
return Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
.putExtra(
Settings.EXTRA_CHANNEL_ID,
NotificationChannels.getInstance().messagesChannel
)
.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
}
override fun parseResult(resultCode: Int, intent: Intent?) = Unit
}

View File

@@ -0,0 +1,78 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.notifications
import android.content.Context
import android.content.Intent
import android.media.RingtoneManager
import android.net.Uri
import android.provider.Settings
import androidx.activity.result.contract.ActivityResultContract
import org.signal.core.util.getParcelableExtraCompat
import org.thoughtcrime.securesms.keyvalue.SignalStore
/**
* Activity result contract for launching the system ringtone picker to select notification sounds.
*
* Supports selecting sounds for both message notifications and call ringtones through the
* Android system's ringtone picker interface.
*
* @param target Specifies whether to configure sounds for messages or calls
*/
class NotificationSoundSelectionContract(
private val target: Target
) : ActivityResultContract<Unit, Uri?>() {
/**
* Defines the type of notification sound to configure.
*/
enum class Target {
/** Message notification sounds */
MESSAGE,
/** Call ringtones */
CALL
}
override fun createIntent(context: Context, input: Unit): Intent {
return when (target) {
Target.MESSAGE -> createIntentForMessageSoundSelection()
Target.CALL -> createIntentForCallSoundSelection()
}
}
private fun createIntentForMessageSoundSelection(): Intent {
val current = SignalStore.settings.messageNotificationSound
return Intent(RingtoneManager.ACTION_RINGTONE_PICKER)
.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true)
.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, true)
.putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_NOTIFICATION)
.putExtra(
RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI,
Settings.System.DEFAULT_NOTIFICATION_URI
)
.putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, current)
}
private fun createIntentForCallSoundSelection(): Intent {
val current = SignalStore.settings.callRingtone
return Intent(RingtoneManager.ACTION_RINGTONE_PICKER)
.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true)
.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, true)
.putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_RINGTONE)
.putExtra(
RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI,
Settings.System.DEFAULT_RINGTONE_URI
)
.putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, current)
}
override fun parseResult(resultCode: Int, intent: Intent?): Uri? {
return intent?.getParcelableExtraCompat(RingtoneManager.EXTRA_RINGTONE_PICKED_URI, Uri::class.java)
}
}

View File

@@ -1,420 +1,615 @@
package org.thoughtcrime.securesms.components.settings.app.notifications
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.graphics.ColorFilter
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.media.Ringtone
import android.media.RingtoneManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import android.text.TextUtils
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContract
import androidx.annotation.RequiresApi
import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModelProvider
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController
import androidx.preference.PreferenceManager
import org.signal.core.util.getParcelableExtraCompat
import kotlinx.coroutines.launch
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.SignalPreview
import org.signal.core.ui.compose.Texts
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.PromptBatterySaverDialogFragment
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.PreferenceModel
import org.thoughtcrime.securesms.components.settings.PreferenceViewHolder
import org.thoughtcrime.securesms.components.settings.RadioListPreference
import org.thoughtcrime.securesms.components.settings.RadioListPreferenceViewHolder
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.app.routes.AppSettingsRoute
import org.thoughtcrime.securesms.components.settings.app.routes.AppSettingsRouter
import org.thoughtcrime.securesms.components.settings.models.Banner
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.notifications.NotificationChannels
import org.thoughtcrime.securesms.notifications.TurnOnNotificationsBottomSheet
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.RingtoneUtil
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.util.viewModel
private const val MESSAGE_SOUND_SELECT: Int = 1
private const val CALL_RINGTONE_SELECT: Int = 2
private val TAG = Log.tag(NotificationsSettingsFragment::class.java)
class NotificationsSettingsFragment : ComposeFragment() {
class NotificationsSettingsFragment : DSLSettingsFragment(R.string.preferences__notifications) {
private val viewModel: NotificationsSettingsViewModel by viewModel {
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
private val repeatAlertsValues by lazy { resources.getStringArray(R.array.pref_repeat_alerts_values) }
private val repeatAlertsLabels by lazy { resources.getStringArray(R.array.pref_repeat_alerts_entries) }
NotificationsSettingsViewModel.Factory(sharedPreferences).create(NotificationsSettingsViewModel::class.java)
}
private val notificationPrivacyValues by lazy { resources.getStringArray(R.array.pref_notification_privacy_values) }
private val notificationPrivacyLabels by lazy { resources.getStringArray(R.array.pref_notification_privacy_entries) }
private val appSettingsRouter: AppSettingsRouter by viewModel {
AppSettingsRouter()
}
private val notificationPriorityValues by lazy { resources.getStringArray(R.array.pref_notification_priority_values) }
private val notificationPriorityLabels by lazy { resources.getStringArray(R.array.pref_notification_priority_entries) }
private lateinit var callbacks: DefaultNotificationsSettingsCallbacks
private val ledColorValues by lazy { resources.getStringArray(R.array.pref_led_color_values) }
private val ledColorLabels by lazy { resources.getStringArray(R.array.pref_led_color_entries) }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
private val ledBlinkValues by lazy { resources.getStringArray(R.array.pref_led_blink_pattern_values) }
private val ledBlinkLabels by lazy { resources.getStringArray(R.array.pref_led_blink_pattern_entries) }
callbacks = DefaultNotificationsSettingsCallbacks(requireActivity(), viewModel, appSettingsRouter, DefaultNotificationsSettingsCallbacks.ActivityResultRegisterer.ForFragment(this))
private lateinit var viewModel: NotificationsSettingsViewModel
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
appSettingsRouter.currentRoute.collect {
when (it) {
AppSettingsRoute.NotificationsRoute.NotificationProfiles -> {
findNavController().safeNavigate(R.id.action_notificationsSettingsFragment_to_notificationProfilesFragment)
}
else -> error("Unexpected route: ${it.javaClass.name}")
}
}
}
}
}
override fun onResume() {
super.onResume()
viewModel.refresh()
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == MESSAGE_SOUND_SELECT && resultCode == Activity.RESULT_OK && data != null) {
val uri: Uri? = data.getParcelableExtraCompat(RingtoneManager.EXTRA_RINGTONE_PICKED_URI, Uri::class.java)
viewModel.setMessageNotificationsSound(uri)
} else if (requestCode == CALL_RINGTONE_SELECT && resultCode == Activity.RESULT_OK && data != null) {
val uri: Uri? = data.getParcelableExtraCompat(RingtoneManager.EXTRA_RINGTONE_PICKED_URI, Uri::class.java)
viewModel.setCallRingtone(uri)
@Composable
override fun FragmentContent() {
val state by viewModel.state.collectAsStateWithLifecycle()
NotificationsSettingsScreen(state = state, callbacks = callbacks)
}
}
/**
* Default callbacks that package up launcher handling and other logic that was in the original fragment.
* This must be called during the creation cycle of the component it is attached to.
*/
open class DefaultNotificationsSettingsCallbacks(
val activity: FragmentActivity,
val viewModel: NotificationsSettingsViewModel,
val appSettingsRouter: AppSettingsRouter,
activityResultRegisterer: ActivityResultRegisterer = ActivityResultRegisterer.ForActivity(activity)
) : NotificationsSettingsCallbacks {
companion object {
private val TAG = Log.tag(DefaultNotificationsSettingsCallbacks::class)
}
interface ActivityResultRegisterer {
fun <I, O> registerForActivityResult(
contract: ActivityResultContract<I, O>,
callback: ActivityResultCallback<O>
): ActivityResultLauncher<I>
class ForActivity(val activity: FragmentActivity) : ActivityResultRegisterer {
override fun <I, O> registerForActivityResult(
contract: ActivityResultContract<I, O>,
callback: ActivityResultCallback<O>
): ActivityResultLauncher<I> {
return activity.registerForActivityResult(contract, callback)
}
}
class ForFragment(val fragment: Fragment) : ActivityResultRegisterer {
override fun <I, O> registerForActivityResult(
contract: ActivityResultContract<I, O>,
callback: ActivityResultCallback<O>
): ActivityResultLauncher<I> {
return fragment.registerForActivityResult(contract, callback)
}
}
}
override fun bindAdapter(adapter: MappingAdapter) {
adapter.registerFactory(
LedColorPreference::class.java,
LayoutFactory(::LedColorPreferenceViewHolder, R.layout.dsl_preference_item)
)
private val messageSoundSelectionLauncher: ActivityResultLauncher<Unit> = activityResultRegisterer.registerForActivityResult(
NotificationSoundSelectionContract(NotificationSoundSelectionContract.Target.MESSAGE),
viewModel::setMessageNotificationsSound
)
Banner.register(adapter)
private val callsSoundSelectionLauncher: ActivityResultLauncher<Unit> = activityResultRegisterer.registerForActivityResult(
NotificationSoundSelectionContract(NotificationSoundSelectionContract.Target.CALL),
viewModel::setCallRingtone
)
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
val factory = NotificationsSettingsViewModel.Factory(sharedPreferences)
private val notificationPrioritySelectionLauncher: ActivityResultLauncher<Unit> = activityResultRegisterer.registerForActivityResult(
contract = NotificationPrioritySelectionContract(),
callback = {}
)
viewModel = ViewModelProvider(this, factory)[NotificationsSettingsViewModel::class.java]
override fun onTurnOnNotificationsActionClick() {
TurnOnNotificationsBottomSheet.turnOnSystemNotificationsFragment(activity).show(activity.supportFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
viewModel.state.observe(viewLifecycleOwner) {
adapter.submitList(getConfiguration(it).toMappingModelList())
override fun onNavigationClick() {
activity.onBackPressedDispatcher.onBackPressed()
}
override fun setMessageNotificationsEnabled(enabled: Boolean) {
viewModel.setMessageNotificationsEnabled(enabled)
}
override fun onCustomizeClick() {
activity.let {
NotificationChannels.getInstance().openChannelSettings(it, NotificationChannels.getInstance().messagesChannel, null)
}
}
private fun getConfiguration(state: NotificationsSettingsState): DSLConfiguration {
return configure {
if (!state.messageNotificationsState.canEnableNotifications) {
customPref(
Banner.Model(
textId = R.string.NotificationSettingsFragment__to_enable_notifications,
actionId = R.string.NotificationSettingsFragment__turn_on,
onClick = {
TurnOnNotificationsBottomSheet.turnOnSystemNotificationsFragment(requireContext()).show(childFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
)
)
}
sectionHeaderPref(R.string.NotificationsSettingsFragment__messages)
switchPref(
title = DSLSettingsText.from(R.string.preferences__notifications),
isEnabled = state.messageNotificationsState.canEnableNotifications,
isChecked = state.messageNotificationsState.notificationsEnabled,
onClick = {
viewModel.setMessageNotificationsEnabled(!state.messageNotificationsState.notificationsEnabled)
}
)
if (Build.VERSION.SDK_INT >= 30) {
clickPref(
title = DSLSettingsText.from(R.string.preferences__customize),
summary = DSLSettingsText.from(R.string.preferences__change_sound_and_vibration),
isEnabled = state.messageNotificationsState.notificationsEnabled,
onClick = {
NotificationChannels.getInstance().openChannelSettings(requireActivity(), NotificationChannels.getInstance().messagesChannel, null)
}
)
} else {
clickPref(
title = DSLSettingsText.from(R.string.preferences__sound),
summary = DSLSettingsText.from(getRingtoneSummary(state.messageNotificationsState.sound)),
isEnabled = state.messageNotificationsState.notificationsEnabled,
onClick = {
launchMessageSoundSelectionIntent()
}
)
switchPref(
title = DSLSettingsText.from(R.string.preferences__vibrate),
isChecked = state.messageNotificationsState.vibrateEnabled,
isEnabled = state.messageNotificationsState.notificationsEnabled,
onClick = {
viewModel.setMessageNotificationVibration(!state.messageNotificationsState.vibrateEnabled)
}
)
customPref(
LedColorPreference(
colorValues = ledColorValues,
radioListPreference = RadioListPreference(
title = DSLSettingsText.from(R.string.preferences__led_color),
listItems = ledColorLabels,
selected = ledColorValues.indexOf(state.messageNotificationsState.ledColor),
isEnabled = state.messageNotificationsState.notificationsEnabled,
onSelected = {
viewModel.setMessageNotificationLedColor(ledColorValues[it])
}
)
)
)
if (!NotificationChannels.supported()) {
radioListPref(
title = DSLSettingsText.from(R.string.preferences__pref_led_blink_title),
listItems = ledBlinkLabels,
selected = ledBlinkValues.indexOf(state.messageNotificationsState.ledBlink),
isEnabled = state.messageNotificationsState.notificationsEnabled,
onSelected = {
viewModel.setMessageNotificationLedBlink(ledBlinkValues[it])
}
)
}
}
switchPref(
title = DSLSettingsText.from(R.string.preferences_notifications__in_chat_sounds),
isChecked = state.messageNotificationsState.inChatSoundsEnabled,
isEnabled = state.messageNotificationsState.notificationsEnabled,
onClick = {
viewModel.setMessageNotificationInChatSoundsEnabled(!state.messageNotificationsState.inChatSoundsEnabled)
}
)
radioListPref(
title = DSLSettingsText.from(R.string.preferences__repeat_alerts),
listItems = repeatAlertsLabels,
selected = repeatAlertsValues.indexOf(state.messageNotificationsState.repeatAlerts.toString()),
isEnabled = state.messageNotificationsState.notificationsEnabled,
onSelected = {
viewModel.setMessageRepeatAlerts(repeatAlertsValues[it].toInt())
}
)
radioListPref(
title = DSLSettingsText.from(R.string.preferences_notifications__show),
listItems = notificationPrivacyLabels,
selected = notificationPrivacyValues.indexOf(state.messageNotificationsState.messagePrivacy),
isEnabled = state.messageNotificationsState.notificationsEnabled,
onSelected = {
viewModel.setMessageNotificationPrivacy(notificationPrivacyValues[it])
}
)
if (Build.VERSION.SDK_INT >= 23 && state.messageNotificationsState.troubleshootNotifications) {
clickPref(
title = DSLSettingsText.from(R.string.preferences_notifications__troubleshoot),
isEnabled = true,
onClick = {
PromptBatterySaverDialogFragment.show(childFragmentManager)
}
)
}
if (Build.VERSION.SDK_INT < 30) {
if (NotificationChannels.supported()) {
clickPref(
title = DSLSettingsText.from(R.string.preferences_notifications__priority),
isEnabled = state.messageNotificationsState.notificationsEnabled,
onClick = {
launchNotificationPriorityIntent()
}
)
} else {
radioListPref(
title = DSLSettingsText.from(R.string.preferences_notifications__priority),
listItems = notificationPriorityLabels,
selected = notificationPriorityValues.indexOf(state.messageNotificationsState.priority.toString()),
isEnabled = state.messageNotificationsState.notificationsEnabled,
onSelected = {
viewModel.setMessageNotificationPriority(notificationPriorityValues[it].toInt())
}
)
}
}
dividerPref()
sectionHeaderPref(R.string.NotificationsSettingsFragment__calls)
switchPref(
title = DSLSettingsText.from(R.string.preferences__notifications),
isEnabled = state.callNotificationsState.canEnableNotifications,
isChecked = state.callNotificationsState.notificationsEnabled,
onClick = {
viewModel.setCallNotificationsEnabled(!state.callNotificationsState.notificationsEnabled)
}
)
clickPref(
title = DSLSettingsText.from(R.string.preferences_notifications__ringtone),
summary = DSLSettingsText.from(getRingtoneSummary(state.callNotificationsState.ringtone)),
isEnabled = state.callNotificationsState.notificationsEnabled,
onClick = {
launchCallRingtoneSelectionIntent()
}
)
switchPref(
title = DSLSettingsText.from(R.string.preferences__vibrate),
isChecked = state.callNotificationsState.vibrateEnabled,
isEnabled = state.callNotificationsState.notificationsEnabled,
onClick = {
viewModel.setCallVibrateEnabled(!state.callNotificationsState.vibrateEnabled)
}
)
dividerPref()
sectionHeaderPref(R.string.NotificationsSettingsFragment__notification_profiles)
clickPref(
title = DSLSettingsText.from(R.string.NotificationsSettingsFragment__profiles),
summary = DSLSettingsText.from(R.string.NotificationsSettingsFragment__create_a_profile_to_receive_notifications_only_from_people_and_groups_you_choose),
onClick = {
findNavController().safeNavigate(R.id.action_notificationsSettingsFragment_to_notificationProfilesFragment)
}
)
dividerPref()
sectionHeaderPref(R.string.NotificationsSettingsFragment__notify_when)
switchPref(
title = DSLSettingsText.from(R.string.NotificationsSettingsFragment__contact_joins_signal),
isChecked = state.notifyWhenContactJoinsSignal,
onClick = {
viewModel.setNotifyWhenContactJoinsSignal(!state.notifyWhenContactJoinsSignal)
}
)
}
}
private fun getRingtoneSummary(uri: Uri): String {
return if (TextUtils.isEmpty(uri.toString())) {
getString(R.string.preferences__silent)
override fun getRingtoneSummary(uri: Uri): String {
return if (uri.toString().isBlank()) {
activity.getString(R.string.preferences__silent)
} else {
val tone: Ringtone? = RingtoneUtil.getRingtone(requireContext(), uri)
val tone: Ringtone? = RingtoneUtil.getRingtone(activity, uri)
if (tone != null) {
try {
tone.getTitle(requireContext()) ?: getString(R.string.NotificationsSettingsFragment__unknown_ringtone)
tone.getTitle(activity) ?: activity.getString(R.string.NotificationsSettingsFragment__unknown_ringtone)
} catch (e: SecurityException) {
Log.w(TAG, "Unable to get title for ringtone", e)
return getString(R.string.NotificationsSettingsFragment__unknown_ringtone)
return activity.getString(R.string.NotificationsSettingsFragment__unknown_ringtone)
}
} else {
getString(R.string.preferences__default)
activity.getString(R.string.preferences__default)
}
}
}
private fun launchMessageSoundSelectionIntent() {
val current = SignalStore.settings.messageNotificationSound
val intent = Intent(RingtoneManager.ACTION_RINGTONE_PICKER)
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true)
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, true)
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_NOTIFICATION)
intent.putExtra(
RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI,
Settings.System.DEFAULT_NOTIFICATION_URI
)
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, current)
openRingtonePicker(intent, MESSAGE_SOUND_SELECT)
}
@RequiresApi(26)
private fun launchNotificationPriorityIntent() {
val intent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
intent.putExtra(
Settings.EXTRA_CHANNEL_ID,
NotificationChannels.getInstance().messagesChannel
)
intent.putExtra(Settings.EXTRA_APP_PACKAGE, requireContext().packageName)
startActivity(intent)
}
private fun launchCallRingtoneSelectionIntent() {
val current = SignalStore.settings.callRingtone
val intent = Intent(RingtoneManager.ACTION_RINGTONE_PICKER)
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true)
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, true)
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_RINGTONE)
intent.putExtra(
RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI,
Settings.System.DEFAULT_RINGTONE_URI
)
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, current)
openRingtonePicker(intent, CALL_RINGTONE_SELECT)
}
@Suppress("DEPRECATION")
private fun openRingtonePicker(intent: Intent, requestCode: Int) {
override fun launchMessageSoundSelectionIntent() {
try {
startActivityForResult(intent, requestCode)
messageSoundSelectionLauncher.launch(Unit)
} catch (e: ActivityNotFoundException) {
Toast.makeText(requireContext(), R.string.NotificationSettingsFragment__failed_to_open_picker, Toast.LENGTH_LONG).show()
Toast.makeText(activity, R.string.NotificationSettingsFragment__failed_to_open_picker, Toast.LENGTH_LONG).show()
}
}
private class LedColorPreference(
val colorValues: Array<String>,
val radioListPreference: RadioListPreference
) : PreferenceModel<LedColorPreference>(
title = radioListPreference.title,
icon = radioListPreference.icon,
summary = radioListPreference.summary
override fun launchCallsSoundSelectionIntent() {
try {
callsSoundSelectionLauncher.launch(Unit)
} catch (e: ActivityNotFoundException) {
Toast.makeText(activity, R.string.NotificationSettingsFragment__failed_to_open_picker, Toast.LENGTH_LONG).show()
}
}
override fun setMessageNotificationVibration(enabled: Boolean) {
viewModel.setMessageNotificationsEnabled(enabled)
}
override fun setMessasgeNotificationLedColor(selection: String) {
viewModel.setMessageNotificationLedColor(selection)
}
override fun setMessasgeNotificationLedBlink(selection: String) {
viewModel.setMessageNotificationLedBlink(selection)
}
override fun setMessageNotificationInChatSoundsEnabled(enabled: Boolean) {
viewModel.setMessageNotificationInChatSoundsEnabled(enabled)
}
override fun setMessageRepeatAlerts(selection: String) {
viewModel.setMessageRepeatAlerts(selection.toInt())
}
override fun setMessageNotificationPrivacy(selection: String) {
viewModel.setMessageNotificationPrivacy(selection)
}
@RequiresApi(23)
override fun onTroubleshootNotificationsClick() {
PromptBatterySaverDialogFragment.show(activity.supportFragmentManager)
}
override fun launchNotificationPriorityIntent() {
notificationPrioritySelectionLauncher.launch(Unit)
}
override fun setMessageNotificationPriority(selection: String) {
viewModel.setMessageNotificationPriority(selection.toInt())
}
override fun setCallNotificationsEnabled(enabled: Boolean) {
viewModel.setCallNotificationsEnabled(enabled)
}
override fun setCallVibrateEnabled(enabled: Boolean) {
viewModel.setCallVibrateEnabled(enabled)
}
override fun onNavigationProfilesClick() {
appSettingsRouter.navigateTo(AppSettingsRoute.NotificationsRoute.NotificationProfiles)
}
override fun setNotifyWhenContactJoinsSignal(enabled: Boolean) {
viewModel.setNotifyWhenContactJoinsSignal(enabled)
}
}
interface NotificationsSettingsCallbacks {
fun onTurnOnNotificationsActionClick() = Unit
fun onNavigationClick() = Unit
fun setMessageNotificationsEnabled(enabled: Boolean) = Unit
fun onCustomizeClick() = Unit
fun getRingtoneSummary(uri: Uri): String = "Test Sound"
fun launchMessageSoundSelectionIntent(): Unit = Unit
fun launchCallsSoundSelectionIntent(): Unit = Unit
fun setMessageNotificationVibration(enabled: Boolean) = Unit
fun setMessasgeNotificationLedColor(selection: String) = Unit
fun setMessasgeNotificationLedBlink(selection: String) = Unit
fun setMessageNotificationInChatSoundsEnabled(enabled: Boolean) = Unit
fun setMessageRepeatAlerts(selection: String) = Unit
fun setMessageNotificationPrivacy(selection: String) = Unit
fun onTroubleshootNotificationsClick() = Unit
fun launchNotificationPriorityIntent() = Unit
fun setMessageNotificationPriority(selection: String) = Unit
fun setCallNotificationsEnabled(enabled: Boolean) = Unit
fun setCallVibrateEnabled(enabled: Boolean) = Unit
fun onNavigationProfilesClick() = Unit
fun setNotifyWhenContactJoinsSignal(enabled: Boolean) = Unit
object Empty : NotificationsSettingsCallbacks
}
@Composable
fun NotificationsSettingsScreen(
state: NotificationsSettingsState,
callbacks: NotificationsSettingsCallbacks,
deviceState: DeviceState = remember { DeviceState() }
) {
Scaffolds.Settings(
title = stringResource(R.string.preferences__notifications),
onNavigationClick = callbacks::onNavigationClick,
navigationIcon = ImageVector.vectorResource(R.drawable.symbol_arrow_start_24)
) {
override fun areContentsTheSame(newItem: LedColorPreference): Boolean {
return super.areContentsTheSame(newItem) && radioListPreference.areContentsTheSame(newItem.radioListPreference)
}
}
LazyColumn(
modifier = Modifier.padding(it)
) {
if (!state.messageNotificationsState.canEnableNotifications) {
item {
Banner(
text = stringResource(R.string.NotificationSettingsFragment__to_enable_notifications),
action = stringResource(R.string.NotificationSettingsFragment__turn_on),
onActionClick = callbacks::onTurnOnNotificationsActionClick
)
}
}
private class LedColorPreferenceViewHolder(itemView: View) :
PreferenceViewHolder<LedColorPreference>(itemView) {
item {
Texts.SectionHeader(stringResource(R.string.NotificationsSettingsFragment__messages))
}
val radioListPreferenceViewHolder = RadioListPreferenceViewHolder(itemView)
item {
Rows.ToggleRow(
text = stringResource(R.string.preferences__notifications),
enabled = state.messageNotificationsState.canEnableNotifications,
checked = state.messageNotificationsState.notificationsEnabled,
onCheckChanged = callbacks::setMessageNotificationsEnabled
)
}
override fun bind(model: LedColorPreference) {
super.bind(model)
radioListPreferenceViewHolder.bind(model.radioListPreference)
summaryView.visibility = View.GONE
val circleDrawable = requireNotNull(ContextCompat.getDrawable(context, R.drawable.circle_tintable))
circleDrawable.setBounds(0, 0, ViewUtil.dpToPx(20), ViewUtil.dpToPx(20))
circleDrawable.colorFilter = model.colorValues[model.radioListPreference.selected].toColorFilter()
if (ViewUtil.isLtr(itemView)) {
titleView.setCompoundDrawables(null, null, circleDrawable, null)
if (deviceState.apiLevel >= 30) {
item {
Rows.TextRow(
text = stringResource(R.string.preferences__customize),
label = stringResource(R.string.preferences__change_sound_and_vibration),
enabled = state.messageNotificationsState.notificationsEnabled,
onClick = callbacks::onCustomizeClick
)
}
} else {
titleView.setCompoundDrawables(circleDrawable, null, null, null)
}
}
item {
Rows.TextRow(
text = stringResource(R.string.preferences__sound),
label = remember(state.messageNotificationsState.sound) {
callbacks.getRingtoneSummary(state.messageNotificationsState.sound)
},
enabled = state.messageNotificationsState.notificationsEnabled,
onClick = callbacks::launchMessageSoundSelectionIntent
)
}
private fun String.toColorFilter(): ColorFilter {
val color = when (this) {
"green" -> ContextCompat.getColor(context, R.color.green_500)
"red" -> ContextCompat.getColor(context, R.color.red_500)
"blue" -> ContextCompat.getColor(context, R.color.blue_500)
"yellow" -> ContextCompat.getColor(context, R.color.yellow_500)
"cyan" -> ContextCompat.getColor(context, R.color.cyan_500)
"magenta" -> ContextCompat.getColor(context, R.color.pink_500)
"white" -> ContextCompat.getColor(context, R.color.white)
else -> ContextCompat.getColor(context, R.color.transparent)
item {
Rows.ToggleRow(
text = stringResource(R.string.preferences__vibrate),
checked = state.messageNotificationsState.vibrateEnabled,
enabled = state.messageNotificationsState.notificationsEnabled,
onCheckChanged = callbacks::setMessageNotificationsEnabled
)
}
item {
Rows.RadioListRow(
text = {
Box(
modifier = Modifier
.clip(CircleShape)
.size(24.dp)
.background(color = getLedColor(state.messageNotificationsState.ledColor))
)
Spacer(modifier = Modifier.size(10.dp))
Text(text = stringResource(R.string.preferences__led_color))
},
dialogTitle = stringResource(R.string.preferences__led_color),
labels = stringArrayResource(R.array.pref_led_color_entries),
values = stringArrayResource(R.array.pref_led_color_values),
selectedValue = state.messageNotificationsState.ledColor,
enabled = state.messageNotificationsState.notificationsEnabled,
onSelected = callbacks::setMessasgeNotificationLedColor
)
}
if (!deviceState.supportsNotificationChannels) {
item {
Rows.RadioListRow(
text = stringResource(R.string.preferences__pref_led_blink_title),
labels = stringArrayResource(R.array.pref_led_blink_pattern_entries),
values = stringArrayResource(R.array.pref_led_blink_pattern_values),
selectedValue = state.messageNotificationsState.ledBlink,
enabled = state.messageNotificationsState.notificationsEnabled,
onSelected = callbacks::setMessasgeNotificationLedBlink
)
}
}
}
return PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
item {
Rows.ToggleRow(
text = stringResource(R.string.preferences_notifications__in_chat_sounds),
checked = state.messageNotificationsState.inChatSoundsEnabled,
enabled = state.messageNotificationsState.notificationsEnabled,
onCheckChanged = callbacks::setMessageNotificationInChatSoundsEnabled
)
}
item {
Rows.RadioListRow(
text = stringResource(R.string.preferences__repeat_alerts),
labels = stringArrayResource(R.array.pref_repeat_alerts_entries),
values = stringArrayResource(R.array.pref_repeat_alerts_values),
selectedValue = state.messageNotificationsState.repeatAlerts.toString(),
enabled = state.messageNotificationsState.notificationsEnabled,
onSelected = callbacks::setMessageRepeatAlerts
)
}
item {
Rows.RadioListRow(
text = stringResource(R.string.preferences_notifications__show),
labels = stringArrayResource(R.array.pref_notification_privacy_entries),
values = stringArrayResource(R.array.pref_notification_privacy_values),
selectedValue = state.messageNotificationsState.messagePrivacy,
enabled = state.messageNotificationsState.notificationsEnabled,
onSelected = callbacks::setMessageNotificationPrivacy
)
}
if (deviceState.apiLevel >= 23 && state.messageNotificationsState.troubleshootNotifications) {
item {
Rows.TextRow(
text = stringResource(R.string.preferences_notifications__troubleshoot),
onClick = callbacks::onTroubleshootNotificationsClick
)
}
}
if (deviceState.apiLevel < 30) {
if (deviceState.supportsNotificationChannels) {
item {
Rows.TextRow(
text = stringResource(R.string.preferences_notifications__priority),
enabled = state.messageNotificationsState.notificationsEnabled,
onClick = callbacks::launchNotificationPriorityIntent
)
}
} else {
item {
Rows.RadioListRow(
text = stringResource(R.string.preferences_notifications__priority),
labels = stringArrayResource(R.array.pref_notification_priority_entries),
values = stringArrayResource(R.array.pref_notification_priority_values),
selectedValue = state.messageNotificationsState.priority.toString(),
enabled = state.messageNotificationsState.notificationsEnabled,
onSelected = callbacks::setMessageNotificationPriority
)
}
}
}
item {
Dividers.Default()
}
item {
Texts.SectionHeader(stringResource(R.string.NotificationsSettingsFragment__calls))
}
item {
Rows.ToggleRow(
text = stringResource(R.string.preferences__notifications),
enabled = state.callNotificationsState.canEnableNotifications,
checked = state.callNotificationsState.notificationsEnabled,
onCheckChanged = callbacks::setCallNotificationsEnabled
)
}
item {
val ringtoneSummary = remember(state.callNotificationsState.ringtone) {
callbacks.getRingtoneSummary(state.callNotificationsState.ringtone)
}
Rows.TextRow(
text = stringResource(R.string.preferences_notifications__ringtone),
label = ringtoneSummary,
enabled = state.callNotificationsState.notificationsEnabled,
onClick = callbacks::launchCallsSoundSelectionIntent
)
}
item {
Rows.ToggleRow(
text = stringResource(R.string.preferences__vibrate),
checked = state.callNotificationsState.vibrateEnabled,
enabled = state.callNotificationsState.notificationsEnabled,
onCheckChanged = callbacks::setCallVibrateEnabled
)
}
item {
Dividers.Default()
}
item {
Texts.SectionHeader(stringResource(R.string.NotificationsSettingsFragment__notification_profiles))
}
item {
Rows.TextRow(
text = stringResource(R.string.NotificationsSettingsFragment__profiles),
label = stringResource(R.string.NotificationsSettingsFragment__create_a_profile_to_receive_notifications_only_from_people_and_groups_you_choose),
onClick = callbacks::onNavigationProfilesClick
)
}
item {
Dividers.Default()
}
item {
Texts.SectionHeader(stringResource(R.string.NotificationsSettingsFragment__notify_when))
}
item {
Rows.ToggleRow(
text = stringResource(R.string.NotificationsSettingsFragment__contact_joins_signal),
checked = state.notifyWhenContactJoinsSignal,
onCheckChanged = callbacks::setNotifyWhenContactJoinsSignal
)
}
}
}
}
@Composable
private fun getLedColor(ledColorString: String): Color {
return when (ledColorString) {
"green" -> colorResource(R.color.green_500)
"red" -> colorResource(R.color.red_500)
"blue" -> colorResource(R.color.blue_500)
"yellow" -> colorResource(R.color.yellow_500)
"cyan" -> colorResource(R.color.cyan_500)
"magenta" -> colorResource(R.color.pink_500)
"white" -> colorResource(R.color.white)
else -> colorResource(R.color.transparent)
}
}
@SignalPreview
@Composable
private fun NotificationsSettingsScreenPreview() {
Previews.Preview {
NotificationsSettingsScreen(
deviceState = rememberTestDeviceState(),
state = rememberTestState(),
callbacks = NotificationsSettingsCallbacks.Empty
)
}
}
@SignalPreview
@Composable
private fun NotificationsSettingsScreenAPI21Preview() {
Previews.Preview {
NotificationsSettingsScreen(
deviceState = rememberTestDeviceState(apiLevel = 21, supportsNotificationChannels = false),
state = rememberTestState(),
callbacks = NotificationsSettingsCallbacks.Empty
)
}
}
@Composable
private fun rememberTestDeviceState(
apiLevel: Int = 35,
supportsNotificationChannels: Boolean = true
): DeviceState = remember {
DeviceState(
apiLevel = apiLevel,
supportsNotificationChannels = supportsNotificationChannels
)
}
@Composable
private fun rememberTestState(): NotificationsSettingsState = remember {
NotificationsSettingsState(
messageNotificationsState = MessageNotificationsState(
notificationsEnabled = true,
canEnableNotifications = true,
sound = Uri.EMPTY,
vibrateEnabled = true,
ledColor = "blue",
ledBlink = "",
inChatSoundsEnabled = true,
repeatAlerts = 1,
messagePrivacy = "",
priority = 1,
troubleshootNotifications = true
),
callNotificationsState = CallNotificationsState(
notificationsEnabled = true,
canEnableNotifications = true,
ringtone = Uri.EMPTY,
vibrateEnabled = true
),
notifyWhenContactJoinsSignal = true
)
}
data class DeviceState(
val apiLevel: Int = Build.VERSION.SDK_INT,
val supportsNotificationChannels: Boolean = NotificationChannels.supported()
)

View File

@@ -3,9 +3,11 @@ package org.thoughtcrime.securesms.components.settings.app.notifications
import android.content.SharedPreferences
import android.net.Uri
import android.os.Build
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.notifications.DeviceSpecificNotificationConfig
@@ -13,13 +15,12 @@ import org.thoughtcrime.securesms.notifications.NotificationChannels
import org.thoughtcrime.securesms.notifications.SlowNotificationHeuristics
import org.thoughtcrime.securesms.preferences.widgets.NotificationPrivacyPreference
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.livedata.Store
class NotificationsSettingsViewModel(private val sharedPreferences: SharedPreferences) : ViewModel() {
private val store = Store(getState())
private val store = MutableStateFlow(getState())
val state: LiveData<NotificationsSettingsState> = store.stateLiveData
val state: StateFlow<NotificationsSettingsState> = store
init {
if (NotificationChannels.supported()) {

View File

@@ -6,6 +6,24 @@
package org.thoughtcrime.securesms.components.settings.models
import androidx.annotation.StringRes
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.signal.core.ui.compose.horizontalGutters
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.databinding.DslBannerBinding
import org.thoughtcrime.securesms.util.adapter.mapping.BindingFactory
import org.thoughtcrime.securesms.util.adapter.mapping.BindingViewHolder
@@ -42,3 +60,53 @@ object Banner {
}
}
}
/**
* Replicates the Banner DSL preference for use in compose components.
*/
@Composable
fun Banner(
text: String,
action: String,
onActionClick: () -> Unit
) {
OutlinedCard(
shape = RoundedCornerShape(18.dp),
border = BorderStroke(width = 1.dp, color = colorResource(R.color.signal_colorOutline_38)),
modifier = Modifier
.horizontalGutters()
.fillMaxWidth()
) {
Column {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.padding(horizontal = 16.57.dp)
.padding(top = 16.dp, bottom = 10.dp)
)
TextButton(
onClick = onActionClick,
modifier = Modifier
.align(Alignment.End)
.padding(horizontal = 8.dp)
) {
Text(text = action)
}
}
}
}
@SignalPreview
@Composable
private fun BannerPreview() {
Previews.Preview {
Banner(
text = "Banner text will go here and probably be about something important",
action = "Action",
onActionClick = {}
)
}
}

View File

@@ -10,6 +10,7 @@
android:layout_marginHorizontal="16dp"
android:layout_marginStart="24dp"
android:layout_marginEnd="24dp"
app:cardBackgroundColor="@color/signal_colorBackground"
app:cardCornerRadius="18dp"
app:cardElevation="0dp"
app:strokeColor="@color/signal_colorOutline_38"

View File

@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -44,6 +45,8 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@@ -372,7 +375,7 @@ object Dialogs {
) {
Surface(
modifier = Modifier
.padding(vertical = 100.dp)
.heightIn(min = 0.dp, max = getScreenHeight() - 200.dp)
.background(
color = SignalTheme.colors.colorSurface2,
shape = AlertDialogDefaults.shape
@@ -456,7 +459,7 @@ object Dialogs {
) {
Surface(
modifier = Modifier
.padding(vertical = 100.dp)
.heightIn(min = 0.dp, max = getScreenHeight() - 200.dp)
.background(
color = SignalTheme.colors.colorSurface2,
shape = AlertDialogDefaults.shape
@@ -587,6 +590,13 @@ object Dialogs {
}
}
}
@Composable
private fun getScreenHeight(): Dp {
return with(LocalDensity.current) {
LocalWindowInfo.current.containerSize.height.toDp()
}
}
}
@SignalPreview

View File

@@ -50,6 +50,7 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import org.signal.core.ui.R
import org.signal.core.ui.compose.Rows.TextAndLabel
import org.signal.core.ui.compose.Rows.TextRow
object Rows {
@@ -150,20 +151,47 @@ object Rows {
labels: Array<String>,
values: Array<String>,
selectedValue: String,
onSelected: (String) -> Unit
onSelected: (String) -> Unit,
enabled: Boolean = true
) {
RadioListRow(
text = { selectedIndex ->
val selectedLabel = if (selectedIndex in labels.indices) {
labels[selectedIndex]
} else {
null
}
TextAndLabel(
text = text,
label = selectedLabel
)
},
dialogTitle = text,
labels = labels,
values = values,
selectedValue = selectedValue,
onSelected = onSelected,
enabled = enabled
)
}
@Composable
fun RadioListRow(
text: @Composable RowScope.(Int) -> Unit,
dialogTitle: String,
labels: Array<String>,
values: Array<String>,
selectedValue: String,
onSelected: (String) -> Unit,
enabled: Boolean = true
) {
val selectedIndex = values.indexOf(selectedValue)
val selectedLabel = if (selectedIndex in labels.indices) {
labels[selectedIndex]
} else {
null
}
var displayDialog by remember { mutableStateOf(false) }
TextRow(
text = text,
label = selectedLabel,
text = { text(selectedIndex) },
enabled = enabled,
onClick = {
displayDialog = true
}
@@ -175,7 +203,7 @@ object Rows {
labels = labels,
values = values,
selectedIndex = selectedIndex,
title = text,
title = dialogTitle,
onSelected = {
onSelected(values[it])
}
@@ -183,18 +211,6 @@ object Rows {
}
}
/*
multiSelectPref(
text = stringResource(R.string.preferences_chats__when_using_mobile_data),
listItems = autoDownloadLabels,
selected = autoDownloadValues.map { state.mobileAutoDownloadValues.contains(it) }.toBooleanArray(),
onSelected = {
val resultSet = it.mapIndexed { index, selected -> if (selected) autoDownloadValues[index] else null }.filterNotNull().toSet()
viewModel.setMobileAutoDownloadValues(resultSet)
}
)
*/
@Composable
fun MultiSelectRow(
text: String,