Add Notification profiles.

This commit is contained in:
Cody Henthorne
2021-12-08 13:22:36 -05:00
parent 31e0696395
commit 6c608e955e
106 changed files with 5692 additions and 238 deletions

View File

@@ -88,11 +88,17 @@ import org.thoughtcrime.securesms.util.VersionTracker;
import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper;
import org.whispersystems.libsignal.logging.SignalProtocolLoggerProvider;
import java.io.IOException;
import java.net.SocketException;
import java.net.SocketTimeoutException;
import java.security.Security;
import java.util.concurrent.TimeUnit;
import io.reactivex.rxjava3.exceptions.OnErrorNotImplementedException;
import io.reactivex.rxjava3.exceptions.UndeliverableException;
import io.reactivex.rxjava3.plugins.RxJavaPlugins;
import io.reactivex.rxjava3.schedulers.Schedulers;
import rxdogtag2.RxDogTag;
/**
* Will be called once when the TextSecure process is created.
@@ -138,10 +144,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
Log.i(TAG, "onCreate()");
})
.addBlocking("crash-handling", this::initializeCrashHandling)
.addBlocking("rx-init", () -> {
RxJavaPlugins.setInitIoSchedulerHandler(schedulerSupplier -> Schedulers.from(SignalExecutors.BOUNDED_IO, true, false));
RxJavaPlugins.setInitComputationSchedulerHandler(schedulerSupplier -> Schedulers.from(SignalExecutors.BOUNDED, true, false));
})
.addBlocking("rx-init", this::initializeRx)
.addBlocking("event-bus", () -> EventBus.builder().logNoSubscriberMessages(false).installDefaultEventBus())
.addBlocking("app-dependencies", this::initializeAppDependencies)
.addBlocking("notification-channels", () -> NotificationChannels.create(this))
@@ -280,6 +283,30 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
Thread.setDefaultUncaughtExceptionHandler(new SignalUncaughtExceptionHandler(originalHandler));
}
private void initializeRx() {
RxDogTag.install();
RxJavaPlugins.setInitIoSchedulerHandler(schedulerSupplier -> Schedulers.from(SignalExecutors.BOUNDED_IO, true, false));
RxJavaPlugins.setInitComputationSchedulerHandler(schedulerSupplier -> Schedulers.from(SignalExecutors.BOUNDED, true, false));
RxJavaPlugins.setErrorHandler(e -> {
boolean wasWrapped = false;
while ((e instanceof UndeliverableException || e instanceof AssertionError || e instanceof OnErrorNotImplementedException) && e.getCause() != null) {
wasWrapped = true;
e = e.getCause();
}
if (wasWrapped && (e instanceof SocketException || e instanceof SocketTimeoutException || e instanceof InterruptedException)) {
return;
}
Thread.UncaughtExceptionHandler uncaughtExceptionHandler = Thread.currentThread().getUncaughtExceptionHandler();
if (uncaughtExceptionHandler == null) {
uncaughtExceptionHandler = Thread.getDefaultUncaughtExceptionHandler();
}
uncaughtExceptionHandler.uncaughtException(Thread.currentThread(), e);
});
}
private void initializeApplicationMigrations() {
ApplicationMigrations.onApplicationCreate(this, ApplicationDependencies.getJobManager());
}

View File

@@ -5,6 +5,7 @@ import android.os.Build
import android.os.Bundle
import android.view.View
import android.widget.EdgeEffect
import androidx.annotation.CallSuper
import androidx.annotation.LayoutRes
import androidx.annotation.MenuRes
import androidx.annotation.StringRes
@@ -24,9 +25,10 @@ abstract class DSLSettingsFragment(
val layoutManagerProducer: (Context) -> RecyclerView.LayoutManager = { context -> LinearLayoutManager(context) }
) : Fragment(layoutId) {
private lateinit var recyclerView: RecyclerView
private lateinit var scrollAnimationHelper: OnScrollAnimationHelper
private var recyclerView: RecyclerView? = null
private var scrollAnimationHelper: OnScrollAnimationHelper? = null
@CallSuper
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
val toolbarShadow: View = view.findViewById(R.id.toolbar_shadow)
@@ -44,16 +46,23 @@ abstract class DSLSettingsFragment(
toolbar.setOnMenuItemClickListener { onOptionsItemSelected(it) }
}
recyclerView = view.findViewById(R.id.recycler)
recyclerView.edgeEffectFactory = EdgeEffectFactory()
scrollAnimationHelper = getOnScrollAnimationHelper(toolbarShadow)
val adapter = DSLSettingsAdapter()
val settingsAdapter = DSLSettingsAdapter()
recyclerView.layoutManager = layoutManagerProducer(requireContext())
recyclerView.adapter = adapter
recyclerView.addOnScrollListener(scrollAnimationHelper)
recyclerView = view.findViewById<RecyclerView>(R.id.recycler).apply {
edgeEffectFactory = EdgeEffectFactory()
layoutManager = layoutManagerProducer(requireContext())
adapter = settingsAdapter
addOnScrollListener(scrollAnimationHelper!!)
}
bindAdapter(adapter)
bindAdapter(settingsAdapter)
}
override fun onDestroyView() {
super.onDestroyView()
recyclerView = null
scrollAnimationHelper = null
}
protected open fun getOnScrollAnimationHelper(toolbarShadow: View): OnScrollAnimationHelper {

View File

@@ -53,6 +53,9 @@ sealed class DSLSettingsText {
override fun modify(context: Context, charSequence: CharSequence): CharSequence {
return SpanUtil.color(textColor, charSequence)
}
override fun equals(other: Any?): Boolean = textColor == (other as? ColorModifier)?.textColor
override fun hashCode(): Int = textColor
}
object CenterModifier : Modifier {
@@ -68,6 +71,9 @@ sealed class DSLSettingsText {
override fun modify(context: Context, charSequence: CharSequence): CharSequence {
return SpanUtil.textAppearance(context, textAppearance, charSequence)
}
override fun equals(other: Any?): Boolean = textAppearance == (other as? TextAppearanceModifier)?.textAppearance
override fun hashCode(): Int = textAppearance
}
object BoldModifier : Modifier {

View File

@@ -9,6 +9,7 @@ import io.reactivex.rxjava3.subjects.Subject
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.EditNotificationProfileScheduleFragmentArgs
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentRepository
import org.thoughtcrime.securesms.help.HelpFragment
@@ -19,6 +20,7 @@ import org.thoughtcrime.securesms.util.CachedInflater
import org.thoughtcrime.securesms.util.DynamicTheme
private const val START_LOCATION = "app.settings.start.location"
private const val START_ARGUMENTS = "app.settings.start.arguments"
private const val NOTIFICATION_CATEGORY = "android.intent.category.NOTIFICATION_PREFERENCES"
private const val STATE_WAS_CONFIGURATION_UPDATED = "app.settings.state.configuration.updated"
@@ -50,6 +52,11 @@ class AppSettingsActivity : DSLSettingsActivity(), DonationPaymentComponent {
StartLocation.SUBSCRIPTIONS -> AppSettingsFragmentDirections.actionDirectToSubscriptions()
StartLocation.BOOST -> AppSettingsFragmentDirections.actionAppSettingsFragmentToBoostsFragment()
StartLocation.MANAGE_SUBSCRIPTIONS -> AppSettingsFragmentDirections.actionDirectToManageDonations()
StartLocation.NOTIFICATION_PROFILES -> AppSettingsFragmentDirections.actionDirectToNotificationProfiles()
StartLocation.CREATE_NOTIFICATION_PROFILE -> AppSettingsFragmentDirections.actionDirectToCreateNotificationProfiles()
StartLocation.NOTIFICATION_PROFILE_DETAILS -> AppSettingsFragmentDirections.actionDirectToNotificationProfileDetails(
EditNotificationProfileScheduleFragmentArgs.fromBundle(intent.getBundleExtra(START_ARGUMENTS)!!).profileId
)
}
}
@@ -131,6 +138,22 @@ class AppSettingsActivity : DSLSettingsActivity(), DonationPaymentComponent {
@JvmStatic
fun manageSubscriptions(context: Context): Intent = getIntentForStartLocation(context, StartLocation.MANAGE_SUBSCRIPTIONS)
@JvmStatic
fun notificationProfiles(context: Context): Intent = getIntentForStartLocation(context, StartLocation.NOTIFICATION_PROFILES)
@JvmStatic
fun createNotificationProfile(context: Context): Intent = getIntentForStartLocation(context, StartLocation.CREATE_NOTIFICATION_PROFILE)
@JvmStatic
fun notificationProfileDetails(context: Context, profileId: Long): Intent {
val arguments = EditNotificationProfileScheduleFragmentArgs.Builder(profileId, false)
.build()
.toBundle()
return getIntentForStartLocation(context, StartLocation.NOTIFICATION_PROFILE_DETAILS)
.putExtra(START_ARGUMENTS, arguments)
}
private fun getIntentForStartLocation(context: Context, startLocation: StartLocation): Intent {
return Intent(context, AppSettingsActivity::class.java)
.putExtra(ARG_NAV_GRAPH, R.navigation.app_settings)
@@ -147,7 +170,10 @@ class AppSettingsActivity : DSLSettingsActivity(), DonationPaymentComponent {
CHANGE_NUMBER(5),
SUBSCRIPTIONS(6),
BOOST(7),
MANAGE_SUBSCRIPTIONS(8);
MANAGE_SUBSCRIPTIONS(8),
NOTIFICATION_PROFILES(9),
CREATE_NOTIFICATION_PROFILE(10),
NOTIFICATION_PROFILE_DETAILS(11);
companion object {
fun fromCode(code: Int?): StartLocation {

View File

@@ -15,6 +15,7 @@ import android.widget.Toast
import androidx.annotation.RequiresApi
import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.fragment.findNavController
import androidx.preference.PreferenceManager
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
@@ -218,6 +219,18 @@ class NotificationsSettingsFragment : DSLSettingsFragment(R.string.preferences__
dividerPref()
sectionHeaderPref(R.string.NotificationsSettingsFragment__notification_profiles)
clickPref(
title = DSLSettingsText.from(R.string.NotificationsSettingsFragment__profiles),
summary = DSLSettingsText.from(R.string.NotificationsSettingsFragment__set_up_notification_profiles),
onClick = {
findNavController().navigate(R.id.action_notificationsSettingsFragment_to_notificationProfilesFragment)
}
)
dividerPref()
sectionHeaderPref(R.string.NotificationsSettingsFragment__notify_when)
switchPref(

View File

@@ -0,0 +1,87 @@
package org.thoughtcrime.securesms.components.settings.app.notifications.manual
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.notifications.manual.models.NotificationProfileSelection
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.NotificationProfilesRepository
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfiles
import org.thoughtcrime.securesms.util.BottomSheetUtil
/**
* BottomSheetDialogFragment that allows a user to select a notification profile to manually enable/disable.
*/
class NotificationProfileSelectionFragment : DSLSettingsBottomSheetFragment() {
private val viewModel: NotificationProfileSelectionViewModel by viewModels(
factoryProducer = {
NotificationProfileSelectionViewModel.Factory(NotificationProfilesRepository())
}
)
override fun bindAdapter(adapter: DSLSettingsAdapter) {
NotificationProfileSelection.register(adapter)
recyclerView.itemAnimator = null
viewModel.state.observe(viewLifecycleOwner) {
adapter.submitList(getConfiguration(it).toMappingModelList())
}
}
private fun getConfiguration(state: NotificationProfileSelectionState): DSLConfiguration {
val activeProfile: NotificationProfile? = NotificationProfiles.getActiveProfile(state.notificationProfiles)
return configure {
state.notificationProfiles.forEach { profile ->
customPref(
NotificationProfileSelection.Entry(
isOn = profile == activeProfile,
summary = if (profile == activeProfile) DSLSettingsText.from(NotificationProfiles.getActiveProfileDescription(requireContext(), profile)) else DSLSettingsText.from(R.string.NotificationProfileDetails__off),
notificationProfile = profile,
isExpanded = profile.id == state.expandedId,
timeSlotB = state.timeSlotB,
onRowClick = viewModel::toggleEnabled,
onTimeSlotAClick = viewModel::enableForOneHour,
onTimeSlotBClick = viewModel::enableUntil,
onToggleClick = viewModel::setExpanded,
onViewSettingsClick = { navigateToSettings(it) }
)
)
space(DimensionUnit.DP.toPixels(16f).toInt())
}
customPref(
NotificationProfileSelection.New(
onClick = {
startActivity(AppSettingsActivity.createNotificationProfile(requireContext()))
dismissAllowingStateLoss()
}
)
)
space(DimensionUnit.DP.toPixels(20f).toInt())
}
}
private fun navigateToSettings(notificationProfile: NotificationProfile) {
startActivity(AppSettingsActivity.notificationProfileDetails(requireContext(), notificationProfile.id))
dismissAllowingStateLoss()
}
companion object {
@JvmStatic
fun show(fragmentManager: FragmentManager) {
NotificationProfileSelectionFragment().show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
}

View File

@@ -0,0 +1,10 @@
package org.thoughtcrime.securesms.components.settings.app.notifications.manual
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import java.util.Calendar
data class NotificationProfileSelectionState(
val notificationProfiles: List<NotificationProfile> = listOf(),
val expandedId: Long = -1L,
val timeSlotB: Calendar
)

View File

@@ -0,0 +1,88 @@
package org.thoughtcrime.securesms.components.settings.app.notifications.manual
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.NotificationProfilesRepository
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.util.livedata.Store
import java.util.Calendar
import java.util.concurrent.TimeUnit
class NotificationProfileSelectionViewModel(private val repository: NotificationProfilesRepository) : ViewModel() {
private val store = Store(NotificationProfileSelectionState(timeSlotB = getTimeSlotB()))
val state: LiveData<NotificationProfileSelectionState> = store.stateLiveData
val disposables = CompositeDisposable()
init {
disposables += repository.getProfiles().subscribeBy(onNext = { profiles -> store.update { it.copy(notificationProfiles = profiles) } })
disposables += Observable
.interval(0, 1, TimeUnit.MINUTES)
.map { getTimeSlotB() }
.distinctUntilChanged()
.subscribe { calendar ->
store.update { it.copy(timeSlotB = calendar) }
}
}
override fun onCleared() {
disposables.clear()
}
fun setExpanded(notificationProfile: NotificationProfile) {
store.update {
it.copy(expandedId = if (it.expandedId == notificationProfile.id) -1L else notificationProfile.id)
}
}
fun toggleEnabled(profile: NotificationProfile) {
disposables += repository.manuallyToggleProfile(profile)
.subscribe()
}
fun enableForOneHour(profile: NotificationProfile) {
disposables += repository.manuallyEnableProfileForDuration(profile.id, System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1))
.subscribe()
}
fun enableUntil(profile: NotificationProfile, calendar: Calendar) {
disposables += repository.manuallyEnableProfileForDuration(profile.id, calendar.timeInMillis)
.subscribe()
}
companion object {
private fun getTimeSlotB(): Calendar {
val now = Calendar.getInstance()
val sixPm = Calendar.getInstance()
val eightAm = Calendar.getInstance()
sixPm.set(Calendar.HOUR_OF_DAY, 18)
sixPm.set(Calendar.MINUTE, 0)
sixPm.set(Calendar.SECOND, 0)
eightAm.set(Calendar.HOUR_OF_DAY, 8)
eightAm.set(Calendar.MINUTE, 0)
eightAm.set(Calendar.SECOND, 0)
return if (now.before(sixPm) && (now.after(eightAm) || now == eightAm)) {
sixPm
} else {
eightAm
}
}
}
class Factory(private val notificationProfilesRepository: NotificationProfilesRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return modelClass.cast(NotificationProfileSelectionViewModel(notificationProfilesRepository))!!
}
}
}

View File

@@ -0,0 +1,133 @@
package org.thoughtcrime.securesms.components.settings.app.notifications.manual.models
import android.view.View
import android.widget.TextView
import androidx.constraintlayout.widget.Group
import com.airbnb.lottie.SimpleColorFilter
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.emoji.EmojiImageView
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
import org.thoughtcrime.securesms.util.visible
import java.util.Calendar
import java.util.Locale
/**
* Notification Profile selection preference.
*/
object NotificationProfileSelection {
private const val TOGGLE_EXPANSION = 0
private const val UPDATE_TIMESLOT = 1
fun register(adapter: MappingAdapter) {
adapter.registerFactory(New::class.java, MappingAdapter.LayoutFactory(::NewViewHolder, R.layout.new_notification_profile_pref))
adapter.registerFactory(Entry::class.java, MappingAdapter.LayoutFactory(::EntryViewHolder, R.layout.notification_profile_entry_pref))
}
class Entry(
val isOn: Boolean,
override val summary: DSLSettingsText,
val notificationProfile: NotificationProfile,
val isExpanded: Boolean,
val timeSlotB: Calendar,
val onRowClick: (NotificationProfile) -> Unit,
val onTimeSlotAClick: (NotificationProfile) -> Unit,
val onTimeSlotBClick: (NotificationProfile, Calendar) -> Unit,
val onViewSettingsClick: (NotificationProfile) -> Unit,
val onToggleClick: (NotificationProfile) -> Unit
) : PreferenceModel<Entry>() {
override fun areItemsTheSame(newItem: Entry): Boolean {
return notificationProfile.id == newItem.notificationProfile.id
}
override fun areContentsTheSame(newItem: Entry): Boolean {
return super.areContentsTheSame(newItem) &&
isOn == newItem.isOn &&
notificationProfile == newItem.notificationProfile &&
isExpanded == newItem.isExpanded &&
timeSlotB == newItem.timeSlotB
}
override fun getChangePayload(newItem: Entry): Any? {
return if (notificationProfile == newItem.notificationProfile && isExpanded != newItem.isExpanded) {
TOGGLE_EXPANSION
} else if (notificationProfile == newItem.notificationProfile && timeSlotB != newItem.timeSlotB) {
UPDATE_TIMESLOT
} else {
null
}
}
}
class EntryViewHolder(itemView: View) : MappingViewHolder<Entry>(itemView) {
private val image: EmojiImageView = findViewById(R.id.notification_preference_image)
private val chevron: View = findViewById(R.id.notification_preference_chevron)
private val name: TextView = findViewById(R.id.notification_preference_name)
private val status: TextView = findViewById(R.id.notification_preference_status)
private val expansion: Group = findViewById(R.id.notification_preference_expanded)
private val timeSlotA: TextView = findViewById(R.id.notification_preference_1hr)
private val timeSlotB: TextView = findViewById(R.id.notification_preference_6pm)
private val viewSettings: View = findViewById(R.id.notification_preference_view_settings)
override fun bind(model: Entry) {
itemView.setOnClickListener { model.onRowClick(model.notificationProfile) }
chevron.setOnClickListener { model.onToggleClick(model.notificationProfile) }
chevron.rotation = if (model.isExpanded) 180f else 0f
timeSlotA.setOnClickListener { model.onTimeSlotAClick(model.notificationProfile) }
timeSlotB.setOnClickListener { model.onTimeSlotBClick(model.notificationProfile, model.timeSlotB) }
viewSettings.setOnClickListener { model.onViewSettingsClick(model.notificationProfile) }
expansion.visible = model.isExpanded
timeSlotB.text = context.getString(
R.string.NotificationProfileSelection__until_s,
DateUtils.getTimeString(context, Locale.getDefault(), model.timeSlotB.timeInMillis)
)
if (TOGGLE_EXPANSION in payload || UPDATE_TIMESLOT in payload) {
return
}
image.background.colorFilter = SimpleColorFilter(model.notificationProfile.color.colorInt())
if (model.notificationProfile.emoji.isNotEmpty()) {
image.setImageEmoji(model.notificationProfile.emoji)
} else {
image.setImageResource(R.drawable.ic_moon_24)
}
name.text = model.notificationProfile.name
presentStatus(model)
timeSlotB.text = context.getString(
R.string.NotificationProfileSelection__until_s,
DateUtils.getTimeString(context, Locale.getDefault(), model.timeSlotB.timeInMillis)
)
itemView.isSelected = model.isOn
}
private fun presentStatus(model: Entry) {
status.isEnabled = model.isOn
status.text = model.summary.resolve(context)
}
}
class New(val onClick: () -> Unit) : PreferenceModel<New>() {
override fun areItemsTheSame(newItem: New): Boolean {
return true
}
}
class NewViewHolder(itemView: View) : MappingViewHolder<New>(itemView) {
override fun bind(model: New) {
itemView.setOnClickListener { model.onClick() }
}
}
}

View File

@@ -0,0 +1,106 @@
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles
import android.graphics.Color
import android.os.Bundle
import android.view.View
import androidx.core.content.ContextCompat
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import com.dd.CircularProgressButton
import com.google.android.material.snackbar.Snackbar
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.models.NotificationProfileAddMembers
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.models.NotificationProfileRecipient
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.conversation.preferences.RecipientPreference
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.LifecycleDisposable
/**
* Show and allow addition of recipients to a profile during the create flow.
*/
class AddAllowedMembersFragment : DSLSettingsFragment(layoutId = R.layout.fragment_add_allowed_members) {
private val viewModel: AddAllowedMembersViewModel by viewModels(factoryProducer = { AddAllowedMembersViewModel.Factory(profileId) })
private val lifecycleDisposable = LifecycleDisposable()
private val profileId: Long by lazy { AddAllowedMembersFragmentArgs.fromBundle(requireArguments()).profileId }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
view.findViewById<CircularProgressButton>(R.id.add_allowed_members_profile_next).apply {
setOnClickListener {
findNavController().navigate(AddAllowedMembersFragmentDirections.actionAddAllowedMembersFragmentToEditNotificationProfileScheduleFragment(profileId, true))
}
}
}
override fun bindAdapter(adapter: DSLSettingsAdapter) {
NotificationProfileAddMembers.register(adapter)
NotificationProfileRecipient.register(adapter)
lifecycleDisposable += viewModel.getProfile()
.subscribeBy(
onNext = { (profile, recipients) ->
adapter.submitList(getConfiguration(profile, recipients).toMappingModelList())
}
)
}
private fun getConfiguration(profile: NotificationProfile, recipients: List<Recipient>): DSLConfiguration {
return configure {
sectionHeaderPref(R.string.AddAllowedMembers__allowed_notifications)
customPref(
NotificationProfileAddMembers.Model(
onClick = { id, currentSelection ->
findNavController().navigate(
AddAllowedMembersFragmentDirections.actionAddAllowedMembersFragmentToSelectRecipientsFragment(id)
.setCurrentSelection(currentSelection.toTypedArray())
)
},
profileId = profile.id,
currentSelection = profile.allowedMembers
)
)
for (member in recipients) {
customPref(
NotificationProfileRecipient.Model(
recipientModel = RecipientPreference.Model(
recipient = member,
onClick = {}
),
onRemoveClick = { id ->
lifecycleDisposable += viewModel.removeMember(id)
.subscribeBy(
onSuccess = { removed ->
view?.let { view ->
Snackbar.make(view, getString(R.string.NotificationProfileDetails__s_removed, removed.getDisplayName(requireContext())), Snackbar.LENGTH_LONG)
.setAction(R.string.NotificationProfileDetails__undo) { undoRemove(id) }
.setActionTextColor(ContextCompat.getColor(requireContext(), R.color.core_ultramarine_light))
.setTextColor(Color.WHITE)
.show()
}
}
)
}
)
)
}
}
}
private fun undoRemove(id: RecipientId) {
lifecycleDisposable += viewModel.addMember(id)
.subscribe()
}
}

View File

@@ -0,0 +1,40 @@
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
class AddAllowedMembersViewModel(private val profileId: Long, private val repository: NotificationProfilesRepository) : ViewModel() {
fun getProfile(): Observable<NotificationProfileAndRecipients> {
return repository.getProfile(profileId)
.map { profile ->
NotificationProfileAndRecipients(profile, profile.allowedMembers.map { Recipient.resolved(it) })
}
.observeOn(AndroidSchedulers.mainThread())
}
fun addMember(id: RecipientId): Single<NotificationProfile> {
return repository.addMember(profileId, id)
.observeOn(AndroidSchedulers.mainThread())
}
fun removeMember(id: RecipientId): Single<Recipient> {
return repository.removeMember(profileId, id)
.map { Recipient.resolved(id) }
.observeOn(AndroidSchedulers.mainThread())
}
data class NotificationProfileAndRecipients(val profile: NotificationProfile, val recipients: List<Recipient>)
class Factory(private val profileId: Long) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(AddAllowedMembersViewModel(profileId, NotificationProfilesRepository()))!!
}
}
}

View File

@@ -0,0 +1,197 @@
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles
import android.os.Bundle
import android.text.Editable
import android.text.TextUtils
import android.view.View
import android.widget.EditText
import android.widget.ImageView
import android.widget.TextView
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.viewModels
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.fragment.findNavController
import com.dd.CircularProgressButton
import com.google.android.material.textfield.TextInputLayout
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.util.BreakIteratorCompat
import org.signal.core.util.EditTextUtil
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.emoji.EmojiUtil
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.EditNotificationProfileViewModel.SaveNotificationProfileResult
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.models.NotificationProfileNamePreset
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.megaphone.Megaphones
import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiBottomSheetDialogFragment
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.CircularProgressButtonUtil
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.text.AfterTextChanged
/**
* Dual use Edit/Create notification profile fragment. Use to create in the create profile flow,
* and then to edit from profile details. Responsible for naming and emoji.
*/
class EditNotificationProfileFragment : DSLSettingsFragment(layoutId = R.layout.fragment_edit_notification_profile), ReactWithAnyEmojiBottomSheetDialogFragment.Callback {
private val viewModel: EditNotificationProfileViewModel by viewModels(factoryProducer = this::createFactory)
private val lifecycleDisposable = LifecycleDisposable()
private var emojiView: ImageView? = null
private var nameView: EditText? = null
private fun createFactory(): ViewModelProvider.Factory {
val profileId = EditNotificationProfileFragmentArgs.fromBundle(requireArguments()).profileId
return EditNotificationProfileViewModel.Factory(profileId)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
ApplicationDependencies.getMegaphoneRepository().markFinished(Megaphones.Event.NOTIFICATION_PROFILES)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
toolbar.setNavigationOnClickListener {
ViewUtil.hideKeyboard(requireContext(), requireView())
requireActivity().onBackPressed()
}
val title: TextView = view.findViewById(R.id.edit_notification_profile_title)
val countView: TextView = view.findViewById(R.id.edit_notification_profile_count)
val saveButton: CircularProgressButton = view.findViewById(R.id.edit_notification_profile_save)
val emojiView: ImageView = view.findViewById(R.id.edit_notification_profile_emoji)
val nameView: EditText = view.findViewById(R.id.edit_notification_profile_name)
val nameTextWrapper: TextInputLayout = view.findViewById(R.id.edit_notification_profile_name_wrapper)
EditTextUtil.addGraphemeClusterLimitFilter(nameView, NOTIFICATION_PROFILE_NAME_MAX_GLYPHS)
nameView.addTextChangedListener(
AfterTextChanged { editable: Editable ->
presentCount(countView, editable.toString())
nameTextWrapper.error = null
}
)
emojiView.setOnClickListener {
ReactWithAnyEmojiBottomSheetDialogFragment.createForAboutSelection()
.show(childFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
view.findViewById<View>(R.id.edit_notification_profile_clear).setOnClickListener {
nameView.setText("")
onEmojiSelectedInternal("")
}
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
saveButton.setOnClickListener {
if (TextUtils.isEmpty(nameView.text)) {
nameTextWrapper.error = getString(R.string.EditNotificationProfileFragment__profile_must_have_a_name)
return@setOnClickListener
}
lifecycleDisposable += viewModel.save(nameView.text.toString())
.doOnSubscribe { CircularProgressButtonUtil.setSpinning(saveButton) }
.doAfterTerminate { CircularProgressButtonUtil.cancelSpinning(saveButton) }
.subscribeBy(
onSuccess = { saveResult ->
when (saveResult) {
is SaveNotificationProfileResult.Success -> {
ViewUtil.hideKeyboard(requireContext(), nameView)
if (saveResult.createMode) {
findNavController().navigate(EditNotificationProfileFragmentDirections.actionEditNotificationProfileFragmentToAddAllowedMembersFragment(saveResult.profile.id))
} else {
findNavController().navigateUp()
}
}
SaveNotificationProfileResult.DuplicateNameFailure -> {
nameTextWrapper.error = getString(R.string.EditNotificationProfileFragment__a_profile_with_this_name_already_exists)
}
}
}
)
}
lifecycleDisposable += viewModel.getInitialState()
.subscribeBy(
onSuccess = { initial ->
if (initial.createMode) {
saveButton.text = getString(R.string.EditNotificationProfileFragment__next)
title.setText(R.string.EditNotificationProfileFragment__name_your_profile)
} else {
saveButton.text = getString(R.string.EditNotificationProfileFragment__save)
title.setText(R.string.EditNotificationProfileFragment__edit_this_profile)
}
nameView.setText(initial.name)
onEmojiSelectedInternal(initial.emoji)
ViewUtil.focusAndMoveCursorToEndAndOpenKeyboard(nameView)
}
)
this.nameView = nameView
this.emojiView = emojiView
}
override fun bindAdapter(adapter: DSLSettingsAdapter) {
NotificationProfileNamePreset.register(adapter)
val onClick = { preset: NotificationProfileNamePreset.Model ->
nameView?.apply {
setText(preset.bodyResource)
setSelection(length(), length())
}
onEmojiSelectedInternal(preset.emoji)
}
adapter.submitList(
listOf(
NotificationProfileNamePreset.Model("\uD83D\uDCAA", R.string.EditNotificationProfileFragment__work, onClick),
NotificationProfileNamePreset.Model("\uD83D\uDE34", R.string.EditNotificationProfileFragment__sleep, onClick),
NotificationProfileNamePreset.Model("\uD83D\uDE97", R.string.EditNotificationProfileFragment__driving, onClick),
NotificationProfileNamePreset.Model("\uD83D\uDE0A", R.string.EditNotificationProfileFragment__downtime, onClick),
NotificationProfileNamePreset.Model("\uD83D\uDCA1", R.string.EditNotificationProfileFragment__focus, onClick),
)
)
}
override fun onReactWithAnyEmojiSelected(emoji: String) {
onEmojiSelectedInternal(emoji)
}
override fun onReactWithAnyEmojiDialogDismissed() = Unit
private fun presentCount(countView: TextView, profileName: String) {
val breakIterator = BreakIteratorCompat.getInstance()
breakIterator.setText(profileName)
val glyphCount = breakIterator.countBreaks()
if (glyphCount >= NOTIFICATION_PROFILE_NAME_LIMIT_DISPLAY_THRESHOLD) {
countView.visibility = View.VISIBLE
countView.text = resources.getString(R.string.EditNotificationProfileFragment__count, glyphCount, NOTIFICATION_PROFILE_NAME_MAX_GLYPHS)
} else {
countView.visibility = View.GONE
}
}
private fun onEmojiSelectedInternal(emoji: String) {
val drawable = EmojiUtil.convertToDrawable(requireContext(), emoji)
if (drawable != null) {
emojiView?.setImageDrawable(drawable)
viewModel.onEmojiSelected(emoji)
} else {
emojiView?.setImageResource(R.drawable.ic_add_emoji)
viewModel.onEmojiSelected("")
}
}
companion object {
private const val NOTIFICATION_PROFILE_NAME_MAX_GLYPHS = 32
private const val NOTIFICATION_PROFILE_NAME_LIMIT_DISPLAY_THRESHOLD = 22
}
}

View File

@@ -0,0 +1,178 @@
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles
import android.os.Bundle
import android.text.Spannable
import android.text.SpannableString
import android.text.format.DateFormat
import android.text.style.AbsoluteSizeSpan
import android.view.View
import android.widget.CheckedTextView
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.widget.Toolbar
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.DrawableCompat
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import com.dd.CircularProgressButton
import com.google.android.material.switchmaterial.SwitchMaterial
import com.google.android.material.timepicker.MaterialTimePicker
import com.google.android.material.timepicker.TimeFormat
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.EditNotificationProfileScheduleViewModel.SaveScheduleResult
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.formatHours
import org.thoughtcrime.securesms.util.visible
import java.time.DayOfWeek
import java.time.LocalTime
import java.time.format.DateTimeFormatter
/**
* Can edit existing or use during create flow to setup a profile schedule.
*/
class EditNotificationProfileScheduleFragment : LoggingFragment(R.layout.fragment_edit_notification_profile_schedule) {
private val viewModel: EditNotificationProfileScheduleViewModel by viewModels(factoryProducer = { EditNotificationProfileScheduleViewModel.Factory(profileId) })
private val lifecycleDisposable = LifecycleDisposable()
private val profileId: Long by lazy { EditNotificationProfileScheduleFragmentArgs.fromBundle(requireArguments()).profileId }
private val createMode: Boolean by lazy { EditNotificationProfileScheduleFragmentArgs.fromBundle(requireArguments()).createMode }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
toolbar.setNavigationOnClickListener { findNavController().navigateUp() }
val title: View = view.findViewById(R.id.edit_notification_profile_schedule_title)
val description: View = view.findViewById(R.id.edit_notification_profile_schedule_description)
toolbar.title = if (!createMode) getString(R.string.EditNotificationProfileSchedule__schedule) else null
title.visible = createMode
description.visible = createMode
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
val enableToggle: SwitchMaterial = view.findViewById(R.id.edit_notification_profile_schedule_switch)
enableToggle.setOnClickListener { viewModel.setEnabled(enableToggle.isChecked) }
val startTime: TextView = view.findViewById(R.id.edit_notification_profile_schedule_start_time)
val endTime: TextView = view.findViewById(R.id.edit_notification_profile_schedule_end_time)
val next: CircularProgressButton = view.findViewById(R.id.edit_notification_profile_schedule__next)
next.setOnClickListener {
lifecycleDisposable += viewModel.save(createMode)
.subscribeBy(
onSuccess = { result ->
when (result) {
SaveScheduleResult.Success -> {
if (createMode) {
findNavController().navigate(EditNotificationProfileScheduleFragmentDirections.actionEditNotificationProfileScheduleFragmentToNotificationProfileCreatedFragment(profileId))
} else {
findNavController().navigateUp()
}
}
SaveScheduleResult.NoDaysSelected -> {
Toast.makeText(requireContext(), R.string.EditNotificationProfileSchedule__schedule_must_have_at_least_one_day, Toast.LENGTH_LONG).show()
}
}
}
)
}
val sunday: CheckedTextView = view.findViewById(R.id.edit_notification_profile_schedule_sunday)
val monday: CheckedTextView = view.findViewById(R.id.edit_notification_profile_schedule_monday)
val tuesday: CheckedTextView = view.findViewById(R.id.edit_notification_profile_schedule_tuesday)
val wednesday: CheckedTextView = view.findViewById(R.id.edit_notification_profile_schedule_wednesday)
val thursday: CheckedTextView = view.findViewById(R.id.edit_notification_profile_schedule_thursday)
val friday: CheckedTextView = view.findViewById(R.id.edit_notification_profile_schedule_friday)
val saturday: CheckedTextView = view.findViewById(R.id.edit_notification_profile_schedule_saturday)
val days: Map<CheckedTextView, DayOfWeek> = mapOf(
sunday to DayOfWeek.SUNDAY,
monday to DayOfWeek.MONDAY,
tuesday to DayOfWeek.TUESDAY,
wednesday to DayOfWeek.WEDNESDAY,
thursday to DayOfWeek.THURSDAY,
friday to DayOfWeek.FRIDAY,
saturday to DayOfWeek.SATURDAY
)
days.forEach { (view, day) ->
DrawableCompat.setTintList(view.background, ContextCompat.getColorStateList(requireContext(), R.color.notification_profile_schedule_background_tint))
view.setOnClickListener { viewModel.toggleDay(day) }
}
lifecycleDisposable += viewModel.schedule()
.subscribeBy(
onNext = { schedule ->
enableToggle.isChecked = schedule.enabled
enableToggle.isEnabled = true
days.forEach { (view, day) ->
view.isChecked = schedule.daysEnabled.contains(day)
view.isEnabled = schedule.enabled
}
startTime.text = schedule.startTime().formatTime()
startTime.setOnClickListener { showTimeSelector(true, schedule.startTime()) }
startTime.isEnabled = schedule.enabled
endTime.text = schedule.endTime().formatTime()
endTime.setOnClickListener { showTimeSelector(false, schedule.endTime()) }
endTime.isEnabled = schedule.enabled
if (createMode) {
next.setText(if (schedule.enabled) R.string.EditNotificationProfileSchedule__next else R.string.EditNotificationProfileSchedule__skip)
} else {
next.setText(R.string.EditNotificationProfileSchedule__save)
}
next.isEnabled = true
}
)
}
private fun showTimeSelector(isStart: Boolean, time: LocalTime) {
val timeFormat = if (DateFormat.is24HourFormat(requireContext())) TimeFormat.CLOCK_24H else TimeFormat.CLOCK_12H
val timePickerFragment = MaterialTimePicker.Builder()
.setTimeFormat(timeFormat)
.setHour(time.hour)
.setMinute(time.minute)
.setTitleText(if (isStart) R.string.EditNotificationProfileSchedule__set_start_time else R.string.EditNotificationProfileSchedule__set_end_time)
.build()
timePickerFragment.addOnDismissListener {
timePickerFragment.clearOnDismissListeners()
timePickerFragment.clearOnPositiveButtonClickListeners()
}
timePickerFragment.addOnPositiveButtonClickListener {
val hour = timePickerFragment.hour
val minute = timePickerFragment.minute
if (isStart) {
viewModel.setStartTime(hour, minute)
} else {
viewModel.setEndTime(hour, minute)
}
}
timePickerFragment.show(childFragmentManager, "TIME_PICKER")
}
}
private fun LocalTime.formatTime(): SpannableString {
val amPm = DateTimeFormatter.ofPattern("a")
.format(this)
val formattedTime: String = this.formatHours()
return SpannableString(formattedTime).apply {
val amPmIndex = formattedTime.indexOf(string = amPm, ignoreCase = true)
if (amPmIndex != -1) {
setSpan(AbsoluteSizeSpan(ViewUtil.spToPx(20f)), amPmIndex, amPmIndex + amPm.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
}

View File

@@ -0,0 +1,90 @@
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.subjects.BehaviorSubject
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileSchedule
import java.time.DayOfWeek
/**
* ViewModel for driving edit schedule UI. UI starts in a disabled state until the first schedule is loaded
* from the database and into the [scheduleSubject] allowing the safe use of !! with [schedule].
*/
class EditNotificationProfileScheduleViewModel(
profileId: Long,
private val repository: NotificationProfilesRepository
) : ViewModel() {
private val disposables = CompositeDisposable()
private val scheduleSubject: BehaviorSubject<NotificationProfileSchedule> = BehaviorSubject.create()
private val schedule: NotificationProfileSchedule
get() = scheduleSubject.value!!
init {
disposables += repository.getProfile(profileId)
.take(1)
.map { it.schedule }
.singleOrError()
.subscribeBy(onSuccess = { scheduleSubject.onNext(it) })
}
override fun onCleared() {
super.onCleared()
disposables.dispose()
}
fun schedule(): Observable<NotificationProfileSchedule> {
return scheduleSubject.subscribeOn(AndroidSchedulers.mainThread())
.observeOn(AndroidSchedulers.mainThread())
}
fun toggleDay(day: DayOfWeek) {
val newDaysEnabled = schedule.daysEnabled.toMutableSet()
if (newDaysEnabled.contains(day)) {
newDaysEnabled.remove(day)
} else {
newDaysEnabled += day
}
scheduleSubject.onNext(schedule.copy(daysEnabled = newDaysEnabled))
}
fun setStartTime(hour: Int, minute: Int) {
scheduleSubject.onNext(schedule.copy(start = hour * 100 + minute))
}
fun setEndTime(hour: Int, minute: Int) {
scheduleSubject.onNext(schedule.copy(end = hour * 100 + minute))
}
fun setEnabled(enabled: Boolean) {
scheduleSubject.onNext(schedule.copy(enabled = enabled))
}
fun save(createMode: Boolean): Single<SaveScheduleResult> {
val result: Single<SaveScheduleResult> = if (schedule.enabled && schedule.daysEnabled.isEmpty()) {
Single.just(SaveScheduleResult.NoDaysSelected)
} else if (createMode && !schedule.enabled) {
Single.just(SaveScheduleResult.Success)
} else {
repository.updateSchedule(schedule).toSingle { SaveScheduleResult.Success }
}
return result.observeOn(AndroidSchedulers.mainThread())
}
class Factory(private val profileId: Long) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(EditNotificationProfileScheduleViewModel(profileId, NotificationProfilesRepository()))!!
}
}
enum class SaveScheduleResult {
NoDaysSelected,
Success
}
}

View File

@@ -0,0 +1,59 @@
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import org.thoughtcrime.securesms.database.NotificationProfileDatabase
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
class EditNotificationProfileViewModel(private val profileId: Long, private val repository: NotificationProfilesRepository) : ViewModel() {
private val createMode: Boolean = profileId == -1L
private var selectedEmoji: String = ""
fun getInitialState(): Single<InitialState> {
val initialState = if (createMode) {
Single.just(InitialState(createMode))
} else {
repository.getProfile(profileId)
.take(1)
.map { InitialState(createMode, it.name, it.emoji) }
.singleOrError()
}
return initialState.observeOn(AndroidSchedulers.mainThread())
}
fun onEmojiSelected(emoji: String) {
selectedEmoji = emoji
}
fun save(name: String): Single<SaveNotificationProfileResult> {
val save = if (createMode) repository.createProfile(name, selectedEmoji) else repository.updateProfile(profileId, name, selectedEmoji)
return save.map { r ->
when (r) {
is NotificationProfileDatabase.NotificationProfileChangeResult.Success -> SaveNotificationProfileResult.Success(r.notificationProfile, createMode)
NotificationProfileDatabase.NotificationProfileChangeResult.DuplicateName -> SaveNotificationProfileResult.DuplicateNameFailure
}
}.observeOn(AndroidSchedulers.mainThread())
}
class Factory(private val profileId: Long) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(EditNotificationProfileViewModel(profileId, NotificationProfilesRepository()))!!
}
}
data class InitialState(
val createMode: Boolean,
val name: String = "",
val emoji: String = ""
)
sealed class SaveNotificationProfileResult {
data class Success(val profile: NotificationProfile, val createMode: Boolean) : SaveNotificationProfileResult()
object DuplicateNameFailure : SaveNotificationProfileResult()
}
}

View File

@@ -0,0 +1,57 @@
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles
import android.os.Bundle
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.navigation.fragment.findNavController
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.LifecycleDisposable
/**
* Shown at the end of the profile create flow.
*/
class NotificationProfileCreatedFragment : LoggingFragment(R.layout.fragment_notification_profile_created) {
private val lifecycleDisposable = LifecycleDisposable()
private val profileId: Long by lazy { NotificationProfileCreatedFragmentArgs.fromBundle(requireArguments()).profileId }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val topIcon: ImageView = view.findViewById(R.id.notification_profile_created_top_image)
val topText: TextView = view.findViewById(R.id.notification_profile_created_top_text)
val bottomIcon: ImageView = view.findViewById(R.id.notification_profile_created_bottom_image)
val bottomText: TextView = view.findViewById(R.id.notification_profile_created_bottom_text)
view.findViewById<View>(R.id.notification_profile_created_done).setOnClickListener {
findNavController().navigate(NotificationProfileCreatedFragmentDirections.actionNotificationProfileCreatedFragmentToNotificationProfileDetailsFragment(profileId))
}
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
val repository = NotificationProfilesRepository()
lifecycleDisposable += repository.getProfile(profileId)
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy(
onNext = { profile ->
if (profile.schedule.enabled) {
topIcon.setImageResource(R.drawable.ic_recent_20)
topText.setText(R.string.NotificationProfileCreated__your_profile_will_turn_on_and_off_automatically_according_to_your_schedule)
bottomIcon.setImageResource(R.drawable.ic_more_vert_24)
bottomText.setText(R.string.NotificationProfileCreated__you_can_turn_your_profile_on_or_off_manually_via_the_menu_on_the_chat_list)
} else {
topIcon.setImageResource(R.drawable.ic_more_vert_24)
topText.setText(R.string.NotificationProfileCreated__you_can_turn_your_profile_on_or_off_manually_via_the_menu_on_the_chat_list)
bottomIcon.setImageResource(R.drawable.ic_recent_20)
bottomText.setText(R.string.NotificationProfileCreated__add_a_schedule_in_settings_to_automate_your_profile)
}
}
)
}
}

View File

@@ -0,0 +1,238 @@
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles
import android.graphics.Color
import android.os.Bundle
import android.view.View
import androidx.appcompat.widget.Toolbar
import androidx.core.content.ContextCompat
import androidx.fragment.app.viewModels
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.fragment.findNavController
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.emoji.EmojiUtil
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.NO_TINT
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.models.NotificationProfileAddMembers
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.models.NotificationProfilePreference
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.models.NotificationProfileRecipient
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.conversation.preferences.RecipientPreference
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileSchedule
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfiles
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.formatHours
import java.time.DayOfWeek
import java.time.format.TextStyle
import java.util.Locale
private val DAY_ORDER: List<DayOfWeek> = listOf(DayOfWeek.SUNDAY, DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY, DayOfWeek.THURSDAY, DayOfWeek.FRIDAY, DayOfWeek.SATURDAY)
class NotificationProfileDetailsFragment : DSLSettingsFragment() {
private val viewModel: NotificationProfileDetailsViewModel by viewModels(factoryProducer = this::createFactory)
private val lifecycleDisposable = LifecycleDisposable()
private var toolbar: Toolbar? = null
private fun createFactory(): ViewModelProvider.Factory {
return NotificationProfileDetailsViewModel.Factory(NotificationProfileDetailsFragmentArgs.fromBundle(requireArguments()).profileId)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
toolbar = view.findViewById(R.id.toolbar)
toolbar?.inflateMenu(R.menu.notification_profile_details)
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
}
override fun onDestroyView() {
super.onDestroyView()
toolbar = null
}
override fun bindAdapter(adapter: DSLSettingsAdapter) {
NotificationProfilePreference.register(adapter)
NotificationProfileAddMembers.register(adapter)
NotificationProfileRecipient.register(adapter)
lifecycleDisposable += viewModel.getProfile()
.subscribeBy(
onNext = { state ->
when (state) {
is NotificationProfileDetailsViewModel.State.Valid -> {
toolbar?.title = state.profile.name
toolbar?.setOnMenuItemClickListener { item ->
if (item.itemId == R.id.action_edit) {
findNavController().navigate(NotificationProfileDetailsFragmentDirections.actionNotificationProfileDetailsFragmentToEditNotificationProfileFragment().setProfileId(state.profile.id))
true
} else {
false
}
}
adapter.submitList(getConfiguration(state.profile, state.recipients, state.isOn).toMappingModelList())
}
NotificationProfileDetailsViewModel.State.Invalid -> findNavController().navigateUp()
}
}
)
}
private fun getConfiguration(profile: NotificationProfile, recipients: List<Recipient>, isOn: Boolean): DSLConfiguration {
return configure {
customPref(
NotificationProfilePreference.Model(
title = DSLSettingsText.from(profile.name),
summary = if (isOn) DSLSettingsText.from(NotificationProfiles.getActiveProfileDescription(requireContext(), profile)) else null,
icon = if (profile.emoji.isNotEmpty()) EmojiUtil.convertToDrawable(requireContext(), profile.emoji)?.let { DSLSettingsIcon.from(it) } else DSLSettingsIcon.from(R.drawable.ic_moon_24, NO_TINT),
color = profile.color,
isOn = isOn,
showSwitch = true,
onClick = {
lifecycleDisposable += viewModel.toggleEnabled(profile)
.subscribe()
}
)
)
dividerPref()
sectionHeaderPref(R.string.AddAllowedMembers__allowed_notifications)
customPref(
NotificationProfileAddMembers.Model(
onClick = { id, currentSelection ->
findNavController().navigate(
NotificationProfileDetailsFragmentDirections.actionNotificationProfileDetailsFragmentToSelectRecipientsFragment(id)
.setCurrentSelection(currentSelection.toTypedArray())
)
},
profileId = profile.id,
currentSelection = profile.allowedMembers
)
)
for (member in recipients) {
customPref(
NotificationProfileRecipient.Model(
recipientModel = RecipientPreference.Model(
recipient = member
),
onRemoveClick = { id ->
lifecycleDisposable += viewModel.removeMember(id)
.subscribeBy(
onSuccess = { removed ->
view?.let { view ->
Snackbar.make(view, getString(R.string.NotificationProfileDetails__s_removed, removed.getDisplayName(requireContext())), Snackbar.LENGTH_LONG)
.setAction(R.string.NotificationProfileDetails__undo) { undoRemove(id) }
.setActionTextColor(ContextCompat.getColor(requireContext(), R.color.core_ultramarine_light))
.setTextColor(Color.WHITE)
.show()
}
}
)
}
)
)
}
dividerPref()
sectionHeaderPref(R.string.NotificationProfileDetails__schedule)
clickPref(
title = DSLSettingsText.from(profile.schedule.describe()),
summary = DSLSettingsText.from(if (profile.schedule.enabled) R.string.NotificationProfileDetails__on else R.string.NotificationProfileDetails__off),
icon = DSLSettingsIcon.from(R.drawable.ic_recent_20, NO_TINT),
onClick = {
findNavController().navigate(NotificationProfileDetailsFragmentDirections.actionNotificationProfileDetailsFragmentToEditNotificationProfileScheduleFragment(profile.id, false))
}
)
dividerPref()
sectionHeaderPref(R.string.NotificationProfileDetails__exceptions)
switchPref(
title = DSLSettingsText.from(R.string.NotificationProfileDetails__allow_all_calls),
isChecked = profile.allowAllCalls,
icon = DSLSettingsIcon.from(R.drawable.ic_phone_right_24),
onClick = {
lifecycleDisposable += viewModel.toggleAllowAllCalls()
.subscribe()
}
)
switchPref(
title = DSLSettingsText.from(R.string.NotificationProfileDetails__notify_for_all_mentions),
icon = DSLSettingsIcon.from(R.drawable.ic_at_24),
isChecked = profile.allowAllMentions,
onClick = {
lifecycleDisposable += viewModel.toggleAllowAllMentions()
.subscribe()
}
)
dividerPref()
clickPref(
title = DSLSettingsText.from(R.string.NotificationProfileDetails__delete_profile, ContextCompat.getColor(requireContext(), R.color.signal_alert_primary)),
icon = DSLSettingsIcon.from(R.drawable.ic_delete_24, R.color.signal_alert_primary),
onClick = {
MaterialAlertDialogBuilder(requireContext())
.setMessage(R.string.NotificationProfileDetails__permanently_delete_profile)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(
SpanUtil.color(
ContextCompat.getColor(requireContext(), R.color.signal_alert_primary),
getString(R.string.NotificationProfileDetails__delete)
)
) { _, _ -> deleteProfile() }
.show()
}
)
}
}
private fun deleteProfile() {
lifecycleDisposable += viewModel.deleteProfile()
.subscribe()
}
private fun undoRemove(id: RecipientId) {
lifecycleDisposable += viewModel.addMember(id)
.subscribe()
}
private fun NotificationProfileSchedule.describe(): String {
if (!enabled) {
return getString(R.string.NotificationProfileDetails__schedule)
}
val startTime = startTime().formatHours()
val endTime = endTime().formatHours()
val days = StringBuilder()
if (daysEnabled.size == 7) {
days.append(getString(R.string.NotificationProfileDetails__everyday))
} else {
for (day in DAY_ORDER) {
if (daysEnabled.contains(day)) {
if (days.isNotEmpty()) {
days.append(", ")
}
days.append(day.getDisplayName(TextStyle.SHORT, Locale.getDefault()))
}
}
}
return getString(R.string.NotificationProfileDetails__s_to_s, startTime, endTime).let { hours ->
if (days.isNotEmpty()) "$hours\n$days" else hours
}
}
}

View File

@@ -0,0 +1,87 @@
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import org.thoughtcrime.securesms.database.NotificationProfileDatabase
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfiles
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
class NotificationProfileDetailsViewModel(private val profileId: Long, private val repository: NotificationProfilesRepository) : ViewModel() {
fun getProfile(): Observable<State> {
return repository.getProfiles()
.map { profiles ->
val profile = profiles.firstOrNull { it.id == profileId }
if (profile == null) {
State.Invalid
} else {
State.Valid(
profile = profile,
recipients = profile.allowedMembers.map { Recipient.resolved(it) },
isOn = NotificationProfiles.getActiveProfile(profiles) == profile
)
}
}
.observeOn(AndroidSchedulers.mainThread())
}
fun addMember(id: RecipientId): Single<NotificationProfile> {
return repository.addMember(profileId, id)
.observeOn(AndroidSchedulers.mainThread())
}
fun removeMember(id: RecipientId): Single<Recipient> {
return repository.removeMember(profileId, id)
.map { Recipient.resolved(id) }
.observeOn(AndroidSchedulers.mainThread())
}
fun deleteProfile(): Completable {
return repository.deleteProfile(profileId)
.observeOn(AndroidSchedulers.mainThread())
}
fun toggleEnabled(profile: NotificationProfile): Completable {
return repository.manuallyToggleProfile(profile)
.observeOn(AndroidSchedulers.mainThread())
}
fun toggleAllowAllMentions(): Single<NotificationProfile> {
return repository.getProfile(profileId)
.take(1)
.singleOrError()
.flatMap { repository.updateProfile(it.copy(allowAllMentions = !it.allowAllMentions)) }
.map { (it as NotificationProfileDatabase.NotificationProfileChangeResult.Success).notificationProfile }
.observeOn(AndroidSchedulers.mainThread())
}
fun toggleAllowAllCalls(): Single<NotificationProfile> {
return repository.getProfile(profileId)
.take(1)
.singleOrError()
.flatMap { repository.updateProfile(it.copy(allowAllCalls = !it.allowAllCalls)) }
.map { (it as NotificationProfileDatabase.NotificationProfileChangeResult.Success).notificationProfile }
.observeOn(AndroidSchedulers.mainThread())
}
sealed class State {
data class Valid(
val profile: NotificationProfile,
val recipients: List<Recipient>,
val isOn: Boolean
) : State()
object Invalid : State()
}
class Factory(private val profileId: Long) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(NotificationProfileDetailsViewModel(profileId, NotificationProfilesRepository()))!!
}
}
}

View File

@@ -0,0 +1,109 @@
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles
import android.os.Bundle
import android.view.View
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.emoji.EmojiUtil
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.NO_TINT
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.models.NoNotificationProfiles
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.models.NotificationProfilePreference
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.conversation.preferences.LargeIconClickPreference
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.megaphone.Megaphones
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfiles
import org.thoughtcrime.securesms.util.LifecycleDisposable
/**
* Primary entry point for Notification Profiles. When user has no profiles, shows empty state, otherwise shows
* all current profiles.
*/
class NotificationProfilesFragment : DSLSettingsFragment() {
private val viewModel: NotificationProfilesViewModel by viewModels(
factoryProducer = { NotificationProfilesViewModel.Factory() }
)
private val lifecycleDisposable = LifecycleDisposable()
private var toolbar: Toolbar? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
ApplicationDependencies.getMegaphoneRepository().markFinished(Megaphones.Event.NOTIFICATION_PROFILES)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
toolbar = view.findViewById(R.id.toolbar)
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
}
override fun onDestroyView() {
super.onDestroyView()
toolbar = null
}
override fun bindAdapter(adapter: DSLSettingsAdapter) {
NoNotificationProfiles.register(adapter)
LargeIconClickPreference.register(adapter)
NotificationProfilePreference.register(adapter)
lifecycleDisposable += viewModel.getProfiles()
.subscribe { profiles ->
if (profiles.isEmpty()) {
toolbar?.title = ""
} else {
toolbar?.setTitle(R.string.NotificationsSettingsFragment__notification_profiles)
}
adapter.submitList(getConfiguration(profiles).toMappingModelList())
}
}
private fun getConfiguration(profiles: List<NotificationProfile>): DSLConfiguration {
return configure {
if (profiles.isEmpty()) {
customPref(
NoNotificationProfiles.Model(
onClick = { findNavController().navigate(R.id.action_notificationProfilesFragment_to_editNotificationProfileFragment) }
)
)
} else {
sectionHeaderPref(R.string.NotificationProfilesFragment__profiles)
customPref(
LargeIconClickPreference.Model(
title = DSLSettingsText.from(R.string.NotificationProfilesFragment__new_profile),
icon = DSLSettingsIcon.from(R.drawable.add_to_a_group, NO_TINT),
onClick = { findNavController().navigate(R.id.action_notificationProfilesFragment_to_editNotificationProfileFragment) }
)
)
val activeProfile: NotificationProfile? = NotificationProfiles.getActiveProfile(profiles)
for (profile: NotificationProfile in profiles) {
customPref(
NotificationProfilePreference.Model(
title = DSLSettingsText.from(profile.name),
summary = if (profile == activeProfile) DSLSettingsText.from(NotificationProfiles.getActiveProfileDescription(requireContext(), profile)) else null,
icon = if (profile.emoji.isNotEmpty()) EmojiUtil.convertToDrawable(requireContext(), profile.emoji)?.let { DSLSettingsIcon.from(it) } else DSLSettingsIcon.from(R.drawable.ic_moon_24, NO_TINT),
color = profile.color,
onClick = {
findNavController().navigate(NotificationProfilesFragmentDirections.actionNotificationProfilesFragmentToNotificationProfileDetailsFragment(profile.id))
}
)
)
}
}
}
}
}

View File

@@ -0,0 +1,131 @@
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.ObservableEmitter
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.database.DatabaseObserver
import org.thoughtcrime.securesms.database.NotificationProfileDatabase
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileSchedule
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfiles
import org.thoughtcrime.securesms.recipients.RecipientId
/**
* One stop shop for all your Notification Profile data needs.
*/
class NotificationProfilesRepository {
private val database: NotificationProfileDatabase = SignalDatabase.notificationProfiles
fun getProfiles(): Observable<List<NotificationProfile>> {
return Observable.create { emitter: ObservableEmitter<List<NotificationProfile>> ->
val databaseObserver: DatabaseObserver = ApplicationDependencies.getDatabaseObserver()
val profileObserver = DatabaseObserver.Observer { emitter.onNext(database.getProfiles()) }
databaseObserver.registerNotificationProfileObserver(profileObserver)
emitter.setCancellable { databaseObserver.unregisterObserver(profileObserver) }
emitter.onNext(database.getProfiles())
}.subscribeOn(Schedulers.io())
}
fun getProfile(profileId: Long): Observable<NotificationProfile> {
return Observable.create { emitter: ObservableEmitter<NotificationProfile> ->
val emitProfile: () -> Unit = {
val profile: NotificationProfile? = database.getProfile(profileId)
if (profile != null) {
emitter.onNext(profile)
} else {
emitter.onError(NotificationProfileNotFoundException())
}
}
val databaseObserver: DatabaseObserver = ApplicationDependencies.getDatabaseObserver()
val profileObserver = DatabaseObserver.Observer { emitProfile() }
databaseObserver.registerNotificationProfileObserver(profileObserver)
emitter.setCancellable { databaseObserver.unregisterObserver(profileObserver) }
emitProfile()
}.subscribeOn(Schedulers.io())
}
fun createProfile(name: String, selectedEmoji: String): Single<NotificationProfileDatabase.NotificationProfileChangeResult> {
return Single.fromCallable { database.createProfile(name = name, emoji = selectedEmoji, color = AvatarColor.random(), createdAt = System.currentTimeMillis()) }
.subscribeOn(Schedulers.io())
}
fun updateProfile(profileId: Long, name: String, selectedEmoji: String): Single<NotificationProfileDatabase.NotificationProfileChangeResult> {
return Single.fromCallable { database.updateProfile(profileId = profileId, name = name, emoji = selectedEmoji) }
.subscribeOn(Schedulers.io())
}
fun updateProfile(profile: NotificationProfile): Single<NotificationProfileDatabase.NotificationProfileChangeResult> {
return Single.fromCallable { database.updateProfile(profile) }
.subscribeOn(Schedulers.io())
}
fun updateAllowedMembers(profileId: Long, recipients: Set<RecipientId>): Single<NotificationProfile> {
return Single.fromCallable { database.setAllowedRecipients(profileId, recipients) }
.subscribeOn(Schedulers.io())
}
fun removeMember(profileId: Long, recipientId: RecipientId): Single<NotificationProfile> {
return Single.fromCallable { database.removeAllowedRecipient(profileId, recipientId) }
.subscribeOn(Schedulers.io())
}
fun addMember(profileId: Long, recipientId: RecipientId): Single<NotificationProfile> {
return Single.fromCallable { database.addAllowedRecipient(profileId, recipientId) }
.subscribeOn(Schedulers.io())
}
fun deleteProfile(profileId: Long): Completable {
return Completable.fromCallable { database.deleteProfile(profileId) }
.subscribeOn(Schedulers.io())
}
fun updateSchedule(schedule: NotificationProfileSchedule): Completable {
return Completable.fromCallable { database.updateSchedule(schedule) }
.subscribeOn(Schedulers.io())
}
fun manuallyToggleProfile(profile: NotificationProfile, now: Long = System.currentTimeMillis()): Completable {
return Completable.fromAction {
val profiles = database.getProfiles()
val activeProfile = NotificationProfiles.getActiveProfile(profiles, now)
if (profile.id == activeProfile?.id) {
SignalStore.notificationProfileValues().manuallyEnabledProfile = 0
SignalStore.notificationProfileValues().manuallyEnabledUntil = 0
SignalStore.notificationProfileValues().manuallyDisabledAt = now
SignalStore.notificationProfileValues().lastProfilePopup = 0
SignalStore.notificationProfileValues().lastProfilePopupTime = 0
} else {
val inScheduledWindow = profile.schedule.isCurrentlyActive(now)
SignalStore.notificationProfileValues().manuallyEnabledProfile = if (inScheduledWindow) 0 else profile.id
SignalStore.notificationProfileValues().manuallyEnabledUntil = if (inScheduledWindow) 0 else Long.MAX_VALUE
SignalStore.notificationProfileValues().manuallyDisabledAt = if (inScheduledWindow) 0 else now
}
}
.doOnComplete { ApplicationDependencies.getDatabaseObserver().notifyNotificationProfileObservers() }
.subscribeOn(Schedulers.io())
}
fun manuallyEnableProfileForDuration(profileId: Long, enableUntil: Long, now: Long = System.currentTimeMillis()): Completable {
return Completable.fromAction {
SignalStore.notificationProfileValues().manuallyEnabledProfile = profileId
SignalStore.notificationProfileValues().manuallyEnabledUntil = enableUntil
SignalStore.notificationProfileValues().manuallyDisabledAt = now
}
.doOnComplete { ApplicationDependencies.getDatabaseObserver().notifyNotificationProfileObservers() }
.subscribeOn(Schedulers.io())
}
class NotificationProfileNotFoundException : Throwable()
}

View File

@@ -0,0 +1,21 @@
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
class NotificationProfilesViewModel(private val repository: NotificationProfilesRepository) : ViewModel() {
fun getProfiles(): Observable<List<NotificationProfile>> {
return repository.getProfiles()
.observeOn(AndroidSchedulers.mainThread())
}
class Factory() : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(NotificationProfilesViewModel(NotificationProfilesRepository()))!!
}
}
}

View File

@@ -0,0 +1,139 @@
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.viewModels
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.fragment.findNavController
import com.dd.CircularProgressButton
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.thoughtcrime.securesms.ContactSelectionListFragment
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ContactFilterView
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader
import org.thoughtcrime.securesms.groups.SelectionLimits
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.CircularProgressButtonUtil
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.ViewUtil
import org.whispersystems.libsignal.util.guava.Optional
import java.util.function.Consumer
/**
* Contact Selection for adding recipients to a Notification Profile.
*/
class SelectRecipientsFragment : LoggingFragment(), ContactSelectionListFragment.OnContactSelectedListener {
private val viewModel: SelectRecipientsViewModel by viewModels(factoryProducer = this::createFactory)
private val lifecycleDisposable = LifecycleDisposable()
private var addToProfile: CircularProgressButton? = null
private fun createFactory(): ViewModelProvider.Factory {
val args = SelectRecipientsFragmentArgs.fromBundle(requireArguments())
return SelectRecipientsViewModel.Factory(args.profileId, args.currentSelection?.toSet() ?: emptySet())
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val currentSelection: Array<RecipientId>? = SelectRecipientsFragmentArgs.fromBundle(requireArguments()).currentSelection
val selectionList = ArrayList<RecipientId>()
if (currentSelection != null) {
selectionList.addAll(currentSelection)
}
childFragmentManager.addFragmentOnAttachListener { _, fragment ->
fragment.arguments = Bundle().apply {
putInt(ContactSelectionListFragment.DISPLAY_MODE, getDefaultDisplayMode())
putBoolean(ContactSelectionListFragment.REFRESHABLE, false)
putBoolean(ContactSelectionListFragment.RECENTS, false)
putParcelable(ContactSelectionListFragment.SELECTION_LIMITS, SelectionLimits.NO_LIMITS)
putParcelableArrayList(ContactSelectionListFragment.CURRENT_SELECTION, selectionList)
putBoolean(ContactSelectionListFragment.HIDE_COUNT, true)
putBoolean(ContactSelectionListFragment.DISPLAY_CHIPS, false)
putBoolean(ContactSelectionListFragment.CAN_SELECT_SELF, false)
putBoolean(ContactSelectionListFragment.RV_CLIP, false)
putInt(ContactSelectionListFragment.RV_PADDING_BOTTOM, ViewUtil.dpToPx(60))
}
}
return inflater.inflate(R.layout.fragment_select_recipients_fragment, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
toolbar.setTitle(R.string.AddAllowedMembers__allowed_notifications)
toolbar.setNavigationOnClickListener { findNavController().navigateUp() }
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
val contactFilterView: ContactFilterView = view.findViewById(R.id.contact_filter_edit_text)
val selectionFragment: ContactSelectionListFragment = childFragmentManager.findFragmentById(R.id.contact_selection_list_fragment) as ContactSelectionListFragment
contactFilterView.setOnFilterChangedListener {
if (it.isNullOrEmpty()) {
selectionFragment.resetQueryFilter()
} else {
selectionFragment.setQueryFilter(it)
}
}
addToProfile = view.findViewById(R.id.select_recipients_add)
addToProfile?.setOnClickListener {
lifecycleDisposable += viewModel.updateAllowedMembers()
.doOnSubscribe { CircularProgressButtonUtil.setSpinning(addToProfile) }
.doOnTerminate { CircularProgressButtonUtil.cancelSpinning(addToProfile) }
.subscribeBy(onSuccess = { findNavController().navigateUp() })
}
updateAddToProfile()
}
override fun onDestroyView() {
super.onDestroyView()
addToProfile = null
}
private fun getDefaultDisplayMode(): Int {
var mode = ContactsCursorLoader.DisplayMode.FLAG_PUSH or
ContactsCursorLoader.DisplayMode.FLAG_ACTIVE_GROUPS or
ContactsCursorLoader.DisplayMode.FLAG_HIDE_NEW or
ContactsCursorLoader.DisplayMode.FLAG_HIDE_RECENT_HEADER
if (Util.isDefaultSmsProvider(requireContext())) {
mode = mode or ContactsCursorLoader.DisplayMode.FLAG_SMS
}
return mode or ContactsCursorLoader.DisplayMode.FLAG_HIDE_GROUPS_V1
}
override fun onBeforeContactSelected(recipientId: Optional<RecipientId>, number: String, callback: Consumer<Boolean>) {
if (recipientId.isPresent) {
viewModel.select(recipientId.get())
callback.accept(true)
updateAddToProfile()
} else {
callback.accept(false)
}
}
override fun onContactDeselected(recipientId: Optional<RecipientId>, number: String) {
if (recipientId.isPresent) {
viewModel.deselect(recipientId.get())
updateAddToProfile()
}
}
override fun onSelectionChanged() = Unit
private fun updateAddToProfile() {
val enabled = viewModel.recipients.isNotEmpty()
addToProfile?.isEnabled = enabled
addToProfile?.alpha = if (enabled) 1f else 0.5f
}
}

View File

@@ -0,0 +1,36 @@
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.recipients.RecipientId
class SelectRecipientsViewModel(
private val profileId: Long,
currentSelection: Set<RecipientId>,
private val repository: NotificationProfilesRepository
) : ViewModel() {
val recipients: MutableSet<RecipientId> = currentSelection.toMutableSet()
fun select(recipientId: RecipientId) {
recipients += recipientId
}
fun deselect(recipientId: RecipientId) {
recipients.remove(recipientId)
}
fun updateAllowedMembers(): Single<NotificationProfile> {
return repository.updateAllowedMembers(profileId, recipients)
.observeOn(AndroidSchedulers.mainThread())
}
class Factory(private val profileId: Long, val currentSelection: Set<RecipientId>) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(SelectRecipientsViewModel(profileId, currentSelection, NotificationProfilesRepository()))!!
}
}
}

View File

@@ -0,0 +1,35 @@
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles.models
import android.view.View
import android.widget.ImageView
import com.airbnb.lottie.SimpleColorFilter
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
/**
* DSL custom preference for showing no profiles/empty state.
*/
object NoNotificationProfiles {
fun register(adapter: MappingAdapter) {
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.notification_profiles_empty))
}
class Model(val onClick: () -> Unit) : PreferenceModel<Model>() {
override fun areItemsTheSame(newItem: Model): Boolean = true
}
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
private val icon: ImageView = findViewById(R.id.notification_profiles_empty_icon)
private val button: View = findViewById(R.id.notification_profiles_empty_create_profile)
override fun bind(model: Model) {
icon.background.colorFilter = SimpleColorFilter(AvatarColor.A100.colorInt())
button.setOnClickListener { model.onClick() }
}
}
}

View File

@@ -0,0 +1,40 @@
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles.models
import android.view.View
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.NO_TINT
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.components.settings.PreferenceViewHolder
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.MappingAdapter
/**
* Custom DSL preference for adding members to a profile.
*/
object NotificationProfileAddMembers {
fun register(adapter: MappingAdapter) {
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory(::ViewHolder, R.layout.large_icon_preference_item))
}
class Model(
override val title: DSLSettingsText = DSLSettingsText.from(R.string.AddAllowedMembers__add_people_or_groups),
override val icon: DSLSettingsIcon = DSLSettingsIcon.from(R.drawable.add_to_a_group, NO_TINT),
val onClick: (Long, Set<RecipientId>) -> Unit,
val profileId: Long,
val currentSelection: Set<RecipientId>
) : PreferenceModel<Model>() {
override fun areContentsTheSame(newItem: Model): Boolean {
return super.areContentsTheSame(newItem) && profileId == newItem.profileId && currentSelection == newItem.currentSelection
}
}
private class ViewHolder(itemView: View) : PreferenceViewHolder<Model>(itemView) {
override fun bind(model: Model) {
super.bind(model)
itemView.setOnClickListener { model.onClick(model.profileId, model.currentSelection) }
}
}
}

View File

@@ -0,0 +1,42 @@
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles.models
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.annotation.StringRes
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.emoji.EmojiUtil
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingModel
import org.thoughtcrime.securesms.util.MappingViewHolder
/**
* DSL custom preference for showing default emoji/name combos for create/edit profile.
*/
object NotificationProfileNamePreset {
fun register(adapter: MappingAdapter) {
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.about_preset_item))
}
class Model(val emoji: String, @StringRes val bodyResource: Int, val onClick: (Model) -> Unit) : MappingModel<Model> {
override fun areItemsTheSame(newItem: Model): Boolean {
return bodyResource == newItem.bodyResource
}
override fun areContentsTheSame(newItem: Model): Boolean {
return areItemsTheSame(newItem) && emoji == newItem.emoji
}
}
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
val emoji: ImageView = findViewById(R.id.about_preset_emoji)
val body: TextView = findViewById(R.id.about_preset_body)
override fun bind(model: Model) {
itemView.setOnClickListener { model.onClick(model) }
emoji.setImageDrawable(EmojiUtil.convertToDrawable(context, model.emoji))
body.setText(model.bodyResource)
}
}
}

View File

@@ -0,0 +1,47 @@
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles.models
import android.view.View
import com.airbnb.lottie.SimpleColorFilter
import com.google.android.material.switchmaterial.SwitchMaterial
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
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.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.visible
/**
* DSL custom preference for showing Notification Profile rows.
*/
object NotificationProfilePreference {
fun register(adapter: MappingAdapter) {
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory(::ViewHolder, R.layout.notification_profile_preference_item))
}
class Model(
override val title: DSLSettingsText,
override val summary: DSLSettingsText?,
override val icon: DSLSettingsIcon?,
val color: AvatarColor,
val isOn: Boolean = false,
val showSwitch: Boolean = false,
val onClick: () -> Unit
) : PreferenceModel<Model>()
private class ViewHolder(itemView: View) : PreferenceViewHolder<Model>(itemView) {
private val switchWidget: SwitchMaterial = itemView.findViewById(R.id.switch_widget)
override fun bind(model: Model) {
super.bind(model)
itemView.setOnClickListener { model.onClick() }
switchWidget.visible = model.showSwitch
switchWidget.isEnabled = model.isEnabled
switchWidget.isChecked = model.isOn
iconView.background.colorFilter = SimpleColorFilter(model.color.colorInt())
}
}
}

View File

@@ -0,0 +1,40 @@
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles.models
import android.view.View
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.components.settings.conversation.preferences.RecipientPreference
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
/**
* DSL custom preference for showing recipients in a profile. Delegates most work to [RecipientPreference].
*/
object NotificationProfileRecipient {
fun register(adapter: MappingAdapter) {
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.notification_profile_recipient_list_item))
}
class Model(val recipientModel: RecipientPreference.Model, val onRemoveClick: (RecipientId) -> Unit) : PreferenceModel<Model>() {
override fun areItemsTheSame(newItem: Model): Boolean {
return recipientModel.recipient.id == newItem.recipientModel.recipient.id
}
override fun areContentsTheSame(newItem: Model): Boolean {
return super.areContentsTheSame(newItem) && recipientModel.areContentsTheSame(newItem.recipientModel)
}
}
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
private val recipientViewHolder: RecipientPreference.ViewHolder = RecipientPreference.ViewHolder(itemView)
private val remove: View = findViewById(R.id.recipient_remove)
override fun bind(model: Model) {
recipientViewHolder.bind(model.recipientModel)
remove.setOnClickListener { model.onRemoveClick(model.recipientModel.recipient.id) }
}
}
}

View File

@@ -23,7 +23,7 @@ object RecipientPreference {
class Model(
val recipient: Recipient,
val isAdmin: Boolean = false,
val onClick: () -> Unit
val onClick: (() -> Unit)? = null
) : PreferenceModel<Model>() {
override fun areItemsTheSame(newItem: Model): Boolean {
return recipient.id == newItem.recipient.id
@@ -36,15 +36,19 @@ object RecipientPreference {
}
}
private class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
private val avatar: AvatarImageView = itemView.findViewById(R.id.recipient_avatar)
private val name: TextView = itemView.findViewById(R.id.recipient_name)
private val about: TextView = itemView.findViewById(R.id.recipient_about)
private val admin: View = itemView.findViewById(R.id.admin)
private val about: TextView? = itemView.findViewById(R.id.recipient_about)
private val admin: View? = itemView.findViewById(R.id.admin)
private val badge: BadgeImageView = itemView.findViewById(R.id.recipient_badge)
override fun bind(model: Model) {
itemView.setOnClickListener { model.onClick() }
if (model.onClick != null) {
itemView.setOnClickListener { model.onClick.invoke() }
} else {
itemView.setOnClickListener(null)
}
avatar.setRecipient(model.recipient)
badge.setBadgeFromRecipient(model.recipient)
@@ -56,13 +60,13 @@ object RecipientPreference {
val aboutText = model.recipient.combinedAboutAndEmoji
if (aboutText.isNullOrEmpty()) {
about.visibility = View.GONE
about?.visibility = View.GONE
} else {
about.text = model.recipient.combinedAboutAndEmoji
about.visibility = View.VISIBLE
about?.text = model.recipient.combinedAboutAndEmoji
about?.visibility = View.VISIBLE
}
admin.visible = model.isAdmin
admin?.visible = model.isAdmin
}
}
}

View File

@@ -66,6 +66,7 @@ import com.annimon.stream.Collectors;
import com.annimon.stream.Stream;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.LoggingFragment;
@@ -127,6 +128,7 @@ import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.mms.OutgoingMediaMessage;
import org.thoughtcrime.securesms.mms.PartAuthority;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter;
import org.thoughtcrime.securesms.providers.BlobProvider;
@@ -155,6 +157,7 @@ import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
import org.thoughtcrime.securesms.util.Stopwatch;
import org.thoughtcrime.securesms.util.StorageUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.TopToastPopup;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.WindowUtil;
@@ -162,6 +165,7 @@ import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask;
import org.thoughtcrime.securesms.util.views.AdaptiveActionsToolbar;
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper;
import org.whispersystems.libsignal.util.guava.Optional;
import java.io.IOException;
import java.io.InputStream;
@@ -367,6 +371,8 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
ViewUtil.setTopMargin(scrollDateHeader, topMargin + ViewUtil.dpToPx(8));
});
conversationViewModel.getActiveNotificationProfile().observe(getViewLifecycleOwner(), this::updateNotificationProfileStatus);
return view;
}
@@ -1046,6 +1052,16 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
}
}
private void updateNotificationProfileStatus(@NonNull Optional<NotificationProfile> activeProfile) {
if (activeProfile.isPresent() && activeProfile.get().getId() != SignalStore.notificationProfileValues().getLastProfilePopup()) {
requireView().postDelayed(() -> {
SignalStore.notificationProfileValues().setLastProfilePopup(activeProfile.get().getId());
SignalStore.notificationProfileValues().setLastProfilePopupTime(System.currentTimeMillis());
TopToastPopup.show(((ViewGroup) requireView()), R.drawable.ic_moon_16, getString(R.string.ConversationFragment__s_on, activeProfile.get().getName()));
}, 500L);
}
}
private boolean isAtBottom() {
if (list.getChildCount() == 0) return true;

View File

@@ -5,6 +5,7 @@ import android.app.Application;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.LiveDataReactiveStreams;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Observer;
import androidx.lifecycle.Transformations;
@@ -22,6 +23,7 @@ import org.signal.paging.PagedData;
import org.signal.paging.PagingConfig;
import org.signal.paging.PagingController;
import org.signal.paging.ProxyPagingController;
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.NotificationProfilesRepository;
import org.thoughtcrime.securesms.conversation.colors.ChatColors;
import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette;
import org.thoughtcrime.securesms.conversation.colors.NameColor;
@@ -33,6 +35,8 @@ import org.thoughtcrime.securesms.groups.LiveGroup;
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry;
import org.thoughtcrime.securesms.mediasend.Media;
import org.thoughtcrime.securesms.mediasend.MediaRepository;
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile;
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfiles;
import org.thoughtcrime.securesms.ratelimit.RecaptchaRequiredEvent;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
@@ -53,6 +57,10 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import io.reactivex.rxjava3.core.BackpressureStrategy;
import io.reactivex.rxjava3.core.Observable;
public class ConversationViewModel extends ViewModel {
@@ -81,6 +89,7 @@ public class ConversationViewModel extends ViewModel {
private final LiveData<Integer> conversationTopMargin;
private final Store<ThreadAnimationState> threadAnimationStateStore;
private final Observer<ThreadAnimationState> threadAnimationStateStoreDriver;
private final NotificationProfilesRepository notificationProfilesRepository;
private final Map<GroupId, Set<Recipient>> sessionMemberCache = new HashMap<>();
@@ -88,23 +97,24 @@ public class ConversationViewModel extends ViewModel {
private int jumpToPosition;
private ConversationViewModel() {
this.context = ApplicationDependencies.getApplication();
this.mediaRepository = new MediaRepository();
this.conversationRepository = new ConversationRepository();
this.recentMedia = new MutableLiveData<>();
this.threadId = new MutableLiveData<>();
this.showScrollButtons = new MutableLiveData<>(false);
this.hasUnreadMentions = new MutableLiveData<>(false);
this.recipientId = new MutableLiveData<>();
this.events = new SingleLiveEvent<>();
this.pagingController = new ProxyPagingController<>();
this.conversationObserver = pagingController::onDataInvalidated;
this.messageUpdateObserver = pagingController::onDataItemChanged;
this.messageInsertObserver = messageId -> pagingController.onDataItemInserted(messageId, 0);
this.toolbarBottom = new MutableLiveData<>();
this.inlinePlayerHeight = new MutableLiveData<>();
this.conversationTopMargin = Transformations.distinctUntilChanged(LiveDataUtil.combineLatest(toolbarBottom, inlinePlayerHeight, Integer::sum));
this.threadAnimationStateStore = new Store<>(new ThreadAnimationState(-1L, null, false));
this.context = ApplicationDependencies.getApplication();
this.mediaRepository = new MediaRepository();
this.conversationRepository = new ConversationRepository();
this.recentMedia = new MutableLiveData<>();
this.threadId = new MutableLiveData<>();
this.showScrollButtons = new MutableLiveData<>(false);
this.hasUnreadMentions = new MutableLiveData<>(false);
this.recipientId = new MutableLiveData<>();
this.events = new SingleLiveEvent<>();
this.pagingController = new ProxyPagingController<>();
this.conversationObserver = pagingController::onDataInvalidated;
this.messageUpdateObserver = pagingController::onDataItemChanged;
this.messageInsertObserver = messageId -> pagingController.onDataItemInserted(messageId, 0);
this.toolbarBottom = new MutableLiveData<>();
this.inlinePlayerHeight = new MutableLiveData<>();
this.conversationTopMargin = Transformations.distinctUntilChanged(LiveDataUtil.combineLatest(toolbarBottom, inlinePlayerHeight, Integer::sum));
this.threadAnimationStateStore = new Store<>(new ThreadAnimationState(-1L, null, false));
this.notificationProfilesRepository = new NotificationProfilesRepository();
LiveData<Recipient> recipientLiveData = LiveDataUtil.mapAsync(recipientId, Recipient::resolved);
LiveData<ThreadAndRecipient> threadAndRecipient = LiveDataUtil.combineLatest(threadId, recipientLiveData, ThreadAndRecipient::new);
@@ -324,6 +334,13 @@ public class ConversationViewModel extends ViewModel {
});
}
@NonNull LiveData<Optional<NotificationProfile>> getActiveNotificationProfile() {
final Observable<Optional<NotificationProfile>> activeProfile = Observable.combineLatest(Observable.interval(0, 30, TimeUnit.SECONDS), notificationProfilesRepository.getProfiles(), (interval, profiles) -> profiles)
.map(profiles -> Optional.fromNullable(NotificationProfiles.getActiveProfile(profiles)));
return LiveDataReactiveStreams.fromPublisher(activeProfile.toFlowable(BackpressureStrategy.LATEST));
}
long getLastSeen() {
return conversationMetadata.getValue() != null ? conversationMetadata.getValue().getLastSeen() : 0;
}

View File

@@ -54,12 +54,15 @@ import androidx.annotation.WorkerThread;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.appcompat.view.ActionMode;
import androidx.appcompat.widget.ActionMenuView;
import androidx.appcompat.widget.Toolbar;
import androidx.appcompat.widget.TooltipCompat;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.core.content.ContextCompat;
import androidx.core.view.ViewCompat;
import androidx.fragment.app.DialogFragment;
import androidx.lifecycle.ViewModelProviders;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider;
import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
@@ -86,6 +89,7 @@ import org.thoughtcrime.securesms.badges.models.Badge;
import org.thoughtcrime.securesms.badges.self.expired.ExpiredBadgeBottomSheetDialogFragment;
import org.thoughtcrime.securesms.components.RatingManager;
import org.thoughtcrime.securesms.components.SearchToolbar;
import org.thoughtcrime.securesms.components.TooltipPopup;
import org.thoughtcrime.securesms.components.UnreadPaymentsView;
import org.thoughtcrime.securesms.components.menu.ActionItem;
import org.thoughtcrime.securesms.components.menu.SignalBottomActionBar;
@@ -100,6 +104,7 @@ import org.thoughtcrime.securesms.components.reminder.ReminderView;
import org.thoughtcrime.securesms.components.reminder.ServiceOutageReminder;
import org.thoughtcrime.securesms.components.reminder.UnauthorizedReminder;
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
import org.thoughtcrime.securesms.components.settings.app.notifications.manual.NotificationProfileSelectionFragment;
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner;
import org.thoughtcrime.securesms.components.voice.VoiceNotePlayerView;
import org.thoughtcrime.securesms.conversation.ConversationFragment;
@@ -122,6 +127,8 @@ import org.thoughtcrime.securesms.megaphone.MegaphoneViewBuilder;
import org.thoughtcrime.securesms.megaphone.Megaphones;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.notifications.MarkReadReceiver;
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile;
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfiles;
import org.thoughtcrime.securesms.payments.preferences.PaymentsActivity;
import org.thoughtcrime.securesms.payments.preferences.details.PaymentDetailsFragmentArgs;
import org.thoughtcrime.securesms.payments.preferences.details.PaymentDetailsParcelable;
@@ -137,6 +144,7 @@ import org.thoughtcrime.securesms.storage.StorageSyncHelper;
import org.thoughtcrime.securesms.util.AppForegroundObserver;
import org.thoughtcrime.securesms.util.AppStartup;
import org.thoughtcrime.securesms.util.AvatarUtil;
import org.thoughtcrime.securesms.util.BottomSheetUtil;
import org.thoughtcrime.securesms.util.PlayStoreUtil;
import org.thoughtcrime.securesms.util.ServiceUtil;
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
@@ -145,6 +153,7 @@ import org.thoughtcrime.securesms.util.SnapToTopDataObserver;
import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
import org.thoughtcrime.securesms.util.Stopwatch;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.TopToastPopup;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.WindowUtil;
@@ -169,6 +178,7 @@ import java.util.UUID;
import java.util.stream.Collectors;
import static android.app.Activity.RESULT_OK;
import static org.thoughtcrime.securesms.components.TooltipPopup.POSITION_BELOW;
public class ConversationListFragment extends MainFragment implements ActionMode.Callback,
@@ -184,33 +194,35 @@ public class ConversationListFragment extends MainFragment implements ActionMode
private static final int MAXIMUM_PINNED_CONVERSATIONS = 4;
private ActionMode actionMode;
private ConstraintLayout constraintLayout;
private RecyclerView list;
private Stub<ReminderView> reminderView;
private Stub<UnreadPaymentsView> paymentNotificationView;
private Stub<ViewGroup> emptyState;
private TextView searchEmptyState;
private PulsingFloatingActionButton fab;
private PulsingFloatingActionButton cameraFab;
private Stub<SearchToolbar> searchToolbar;
private ImageView proxyStatus;
private ImageView searchAction;
private View toolbarShadow;
private View unreadPaymentsDot;
private ConversationListViewModel viewModel;
private RecyclerView.Adapter activeAdapter;
private ConversationListAdapter defaultAdapter;
private ConversationListSearchAdapter searchAdapter;
private StickyHeaderDecoration searchAdapterDecoration;
private Stub<ViewGroup> megaphoneContainer;
private SnapToTopDataObserver snapToTopDataObserver;
private Drawable archiveDrawable;
private AppForegroundObserver.Listener appForegroundObserver;
private VoiceNoteMediaControllerOwner mediaControllerOwner;
private Stub<FrameLayout> voiceNotePlayerViewStub;
private VoiceNotePlayerView voiceNotePlayerView;
private SignalBottomActionBar bottomActionBar;
private ActionMode actionMode;
private ConstraintLayout constraintLayout;
private RecyclerView list;
private Stub<ReminderView> reminderView;
private Stub<UnreadPaymentsView> paymentNotificationView;
private Stub<ViewGroup> emptyState;
private TextView searchEmptyState;
private PulsingFloatingActionButton fab;
private PulsingFloatingActionButton cameraFab;
private Stub<SearchToolbar> searchToolbar;
private ImageView notificationProfileStatus;
private ImageView proxyStatus;
private ImageView searchAction;
private View toolbarShadow;
private View unreadPaymentsDot;
private ConversationListViewModel viewModel;
private RecyclerView.Adapter activeAdapter;
private ConversationListAdapter defaultAdapter;
private ConversationListSearchAdapter searchAdapter;
private StickyHeaderDecoration searchAdapterDecoration;
private Stub<ViewGroup> megaphoneContainer;
private SnapToTopDataObserver snapToTopDataObserver;
private Drawable archiveDrawable;
private AppForegroundObserver.Listener appForegroundObserver;
private VoiceNoteMediaControllerOwner mediaControllerOwner;
private Stub<FrameLayout> voiceNotePlayerViewStub;
private VoiceNotePlayerView voiceNotePlayerView;
private SignalBottomActionBar bottomActionBar;
private TopToastPopup previousTopToastPopup;
protected ConversationListArchiveItemDecoration archiveDecoration;
protected ConversationListItemAnimator itemAnimator;
@@ -245,27 +257,29 @@ public class ConversationListFragment extends MainFragment implements ActionMode
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
constraintLayout = view.findViewById(R.id.constraint_layout);
list = view.findViewById(R.id.list);
fab = view.findViewById(R.id.fab);
cameraFab = view.findViewById(R.id.camera_fab);
searchEmptyState = view.findViewById(R.id.search_no_results);
searchAction = view.findViewById(R.id.search_action);
toolbarShadow = view.findViewById(R.id.conversation_list_toolbar_shadow);
proxyStatus = view.findViewById(R.id.conversation_list_proxy_status);
unreadPaymentsDot = view.findViewById(R.id.unread_payments_indicator);
bottomActionBar = view.findViewById(R.id.conversation_list_bottom_action_bar);
reminderView = new Stub<>(view.findViewById(R.id.reminder));
emptyState = new Stub<>(view.findViewById(R.id.empty_state));
searchToolbar = new Stub<>(view.findViewById(R.id.search_toolbar));
megaphoneContainer = new Stub<>(view.findViewById(R.id.megaphone_container));
paymentNotificationView = new Stub<>(view.findViewById(R.id.payments_notification));
voiceNotePlayerViewStub = new Stub<>(view.findViewById(R.id.voice_note_player));
constraintLayout = view.findViewById(R.id.constraint_layout);
list = view.findViewById(R.id.list);
fab = view.findViewById(R.id.fab);
cameraFab = view.findViewById(R.id.camera_fab);
searchEmptyState = view.findViewById(R.id.search_no_results);
searchAction = view.findViewById(R.id.search_action);
toolbarShadow = view.findViewById(R.id.conversation_list_toolbar_shadow);
notificationProfileStatus = view.findViewById(R.id.conversation_list_notification_profile_status);
proxyStatus = view.findViewById(R.id.conversation_list_proxy_status);
unreadPaymentsDot = view.findViewById(R.id.unread_payments_indicator);
bottomActionBar = view.findViewById(R.id.conversation_list_bottom_action_bar);
reminderView = new Stub<>(view.findViewById(R.id.reminder));
emptyState = new Stub<>(view.findViewById(R.id.empty_state));
searchToolbar = new Stub<>(view.findViewById(R.id.search_toolbar));
megaphoneContainer = new Stub<>(view.findViewById(R.id.megaphone_container));
paymentNotificationView = new Stub<>(view.findViewById(R.id.payments_notification));
voiceNotePlayerViewStub = new Stub<>(view.findViewById(R.id.voice_note_player));
Toolbar toolbar = getToolbar(view);
toolbar.setVisibility(View.VISIBLE);
((AppCompatActivity) requireActivity()).setSupportActionBar(toolbar);
notificationProfileStatus.setOnClickListener(v -> handleNotificationProfile());
proxyStatus.setOnClickListener(v -> onProxyStatusClicked());
fab.show();
@@ -307,6 +321,12 @@ public class ConversationListFragment extends MainFragment implements ActionMode
TooltipCompat.setTooltipText(searchAction, getText(R.string.SearchToolbar_search_for_conversations_contacts_and_messages));
}
@Override
public void onDestroyView() {
previousTopToastPopup = null;
super.onDestroyView();
}
@Override
public void onResume() {
super.onResume();
@@ -389,12 +409,13 @@ public class ConversationListFragment extends MainFragment implements ActionMode
super.onOptionsItemSelected(item);
switch (item.getItemId()) {
case R.id.menu_new_group: handleCreateGroup(); return true;
case R.id.menu_settings: handleDisplaySettings(); return true;
case R.id.menu_clear_passphrase: handleClearPassphrase(); return true;
case R.id.menu_mark_all_read: handleMarkAllRead(); return true;
case R.id.menu_invite: handleInvite(); return true;
case R.id.menu_insights: handleInsights(); return true;
case R.id.menu_new_group: handleCreateGroup(); return true;
case R.id.menu_settings: handleDisplaySettings(); return true;
case R.id.menu_clear_passphrase: handleClearPassphrase(); return true;
case R.id.menu_mark_all_read: handleMarkAllRead(); return true;
case R.id.menu_invite: handleInvite(); return true;
case R.id.menu_insights: handleInsights(); return true;
case R.id.menu_notification_profile: handleNotificationProfile(); return true;
}
return false;
@@ -652,12 +673,13 @@ public class ConversationListFragment extends MainFragment implements ActionMode
}
private void initializeViewModel() {
viewModel = ViewModelProviders.of(this, new ConversationListViewModel.Factory(isArchived())).get(ConversationListViewModel.class);
viewModel = new ViewModelProvider(this, new ConversationListViewModel.Factory(isArchived())).get(ConversationListViewModel.class);
viewModel.getSearchResult().observe(getViewLifecycleOwner(), this::onSearchResultChanged);
viewModel.getMegaphone().observe(getViewLifecycleOwner(), this::onMegaphoneChanged);
viewModel.getConversationList().observe(getViewLifecycleOwner(), this::onConversationListChanged);
viewModel.hasNoConversations().observe(getViewLifecycleOwner(), this::updateEmptyState);
viewModel.getNotificationProfiles().observe(getViewLifecycleOwner(), this::updateNotificationProfileStatus);
viewModel.getPipeState().observe(getViewLifecycleOwner(), this::updateProxyStatus);
appForegroundObserver = new AppForegroundObserver.Listener() {
@@ -844,6 +866,10 @@ public class ConversationListFragment extends MainFragment implements ActionMode
getNavigator().goToInsights();
}
private void handleNotificationProfile() {
NotificationProfileSelectionFragment.show(getParentFragmentManager());
}
@SuppressLint("StaticFieldLeak")
private void handleArchive(@NonNull Collection<Long> ids, boolean showProgress) {
Set<Long> selectedConversations = new HashSet<>(ids);
@@ -1042,6 +1068,67 @@ public class ConversationListFragment extends MainFragment implements ActionMode
}
}
private void updateNotificationProfileStatus(@NonNull List<NotificationProfile> notificationProfiles) {
if (notificationProfiles.isEmpty()) {
return;
}
if (!SignalStore.notificationProfileValues().getHasSeenTooltip()) {
View target = findOverflowMenuButton(getToolbar(requireView()));
if (target != null) {
TooltipPopup.forTarget(target)
.setText(R.string.ConversationListFragment__turn_your_notification_profile_on_or_off_here)
.setBackgroundTint(ContextCompat.getColor(requireContext(), R.color.signal_button_primary))
.setTextColor(ContextCompat.getColor(requireContext(), R.color.signal_button_primary_text))
.setOnDismissListener(() -> SignalStore.notificationProfileValues().setHasSeenTooltip(true))
.show(POSITION_BELOW);
} else {
Log.w(TAG, "Unable to find overflow menu to show Notification Profile tooltip");
}
}
NotificationProfile activeProfile = NotificationProfiles.getActiveProfile(notificationProfiles);
if (activeProfile != null) {
if (activeProfile.getId() != SignalStore.notificationProfileValues().getLastProfilePopup()) {
requireView().postDelayed(() -> {
SignalStore.notificationProfileValues().setLastProfilePopup(activeProfile.getId());
SignalStore.notificationProfileValues().setLastProfilePopupTime(System.currentTimeMillis());
if (previousTopToastPopup != null && previousTopToastPopup.isShowing()) {
previousTopToastPopup.dismiss();
}
ViewGroup view = ((ViewGroup) requireView());
Fragment fragment = getParentFragmentManager().findFragmentByTag(BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG);
if (fragment != null && fragment.isAdded() && fragment.getView() != null) {
view = ((ViewGroup) fragment.requireView());
}
try {
previousTopToastPopup = TopToastPopup.show(view, R.drawable.ic_moon_16, getString(R.string.ConversationListFragment__s_on, activeProfile.getName()));
} catch (Exception e) {
Log.w(TAG, "Unable to show toast popup", e);
}
}, 500L);
}
notificationProfileStatus.setVisibility(View.VISIBLE);
} else {
notificationProfileStatus.setVisibility(View.GONE);
}
}
private @Nullable View findOverflowMenuButton(@NonNull Toolbar viewGroup) {
for (int i = 0, count = viewGroup.getChildCount(); i < count; i++) {
View v = viewGroup.getChildAt(i);
if (v instanceof ActionMenuView) {
return v;
}
}
return null;
}
private void updateProxyStatus(@NonNull WebSocketConnectionState state) {
if (SignalStore.proxy().isProxyEnabled()) {
proxyStatus.setVisibility(View.VISIBLE);
@@ -1250,7 +1337,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
items.add(new ActionItem(R.drawable.ic_select_24, getString(R.string.ConversationListFragment_select_all), viewModel::onSelectAllClick));
bottomActionBar.setItems(items);
// bottomActionBar.setItems(items);
}
protected Toolbar getToolbar(@NonNull View rootView) {

View File

@@ -14,6 +14,7 @@ import org.signal.core.util.logging.Log;
import org.signal.paging.PagedData;
import org.signal.paging.PagingConfig;
import org.signal.paging.PagingController;
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.NotificationProfilesRepository;
import org.thoughtcrime.securesms.conversationlist.model.Conversation;
import org.thoughtcrime.securesms.conversationlist.model.ConversationSet;
import org.thoughtcrime.securesms.conversationlist.model.UnreadPayments;
@@ -24,6 +25,7 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.megaphone.Megaphone;
import org.thoughtcrime.securesms.megaphone.MegaphoneRepository;
import org.thoughtcrime.securesms.megaphone.Megaphones;
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile;
import org.thoughtcrime.securesms.payments.UnreadPaymentsRepository;
import org.thoughtcrime.securesms.search.SearchRepository;
import org.thoughtcrime.securesms.search.SearchResult;
@@ -39,9 +41,11 @@ import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.BackpressureStrategy;
import io.reactivex.rxjava3.core.Observable;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
@@ -52,50 +56,52 @@ class ConversationListViewModel extends ViewModel {
private static boolean coldStart = true;
private final MutableLiveData<Megaphone> megaphone;
private final MutableLiveData<SearchResult> searchResult;
private final MutableLiveData<ConversationSet> selectedConversations;
private final Set<Conversation> internalSelection;
private final ConversationListDataSource conversationListDataSource;
private final PagedData<Long, Conversation> pagedData;
private final LiveData<Boolean> hasNoConversations;
private final SearchRepository searchRepository;
private final MegaphoneRepository megaphoneRepository;
private final Debouncer messageSearchDebouncer;
private final Debouncer contactSearchDebouncer;
private final ThrottledDebouncer updateDebouncer;
private final DatabaseObserver.Observer observer;
private final Invalidator invalidator;
private final CompositeDisposable disposables;
private final UnreadPaymentsLiveData unreadPaymentsLiveData;
private final UnreadPaymentsRepository unreadPaymentsRepository;
private final MutableLiveData<Megaphone> megaphone;
private final MutableLiveData<SearchResult> searchResult;
private final MutableLiveData<ConversationSet> selectedConversations;
private final Set<Conversation> internalSelection;
private final ConversationListDataSource conversationListDataSource;
private final PagedData<Long, Conversation> pagedData;
private final LiveData<Boolean> hasNoConversations;
private final SearchRepository searchRepository;
private final MegaphoneRepository megaphoneRepository;
private final Debouncer messageSearchDebouncer;
private final Debouncer contactSearchDebouncer;
private final ThrottledDebouncer updateDebouncer;
private final DatabaseObserver.Observer observer;
private final Invalidator invalidator;
private final CompositeDisposable disposables;
private final UnreadPaymentsLiveData unreadPaymentsLiveData;
private final UnreadPaymentsRepository unreadPaymentsRepository;
private final NotificationProfilesRepository notificationProfilesRepository;
private String activeQuery;
private SearchResult activeSearchResult;
private int pinnedCount;
private ConversationListViewModel(@NonNull Application application, @NonNull SearchRepository searchRepository, boolean isArchived) {
this.megaphone = new MutableLiveData<>();
this.searchResult = new MutableLiveData<>();
this.internalSelection = new HashSet<>();
this.selectedConversations = new MutableLiveData<>(new ConversationSet());
this.searchRepository = searchRepository;
this.megaphoneRepository = ApplicationDependencies.getMegaphoneRepository();
this.unreadPaymentsRepository = new UnreadPaymentsRepository();
this.messageSearchDebouncer = new Debouncer(500);
this.contactSearchDebouncer = new Debouncer(100);
this.updateDebouncer = new ThrottledDebouncer(500);
this.activeSearchResult = SearchResult.EMPTY;
this.invalidator = new Invalidator();
this.disposables = new CompositeDisposable();
this.conversationListDataSource = ConversationListDataSource.create(application, isArchived);
this.pagedData = PagedData.create(conversationListDataSource,
new PagingConfig.Builder()
.setPageSize(15)
.setBufferPages(2)
.build());
this.unreadPaymentsLiveData = new UnreadPaymentsLiveData();
this.observer = () -> {
this.megaphone = new MutableLiveData<>();
this.searchResult = new MutableLiveData<>();
this.internalSelection = new HashSet<>();
this.selectedConversations = new MutableLiveData<>(new ConversationSet());
this.searchRepository = searchRepository;
this.megaphoneRepository = ApplicationDependencies.getMegaphoneRepository();
this.unreadPaymentsRepository = new UnreadPaymentsRepository();
this.notificationProfilesRepository = new NotificationProfilesRepository();
this.messageSearchDebouncer = new Debouncer(500);
this.contactSearchDebouncer = new Debouncer(100);
this.updateDebouncer = new ThrottledDebouncer(500);
this.activeSearchResult = SearchResult.EMPTY;
this.invalidator = new Invalidator();
this.disposables = new CompositeDisposable();
this.conversationListDataSource = ConversationListDataSource.create(application, isArchived);
this.pagedData = PagedData.create(conversationListDataSource,
new PagingConfig.Builder()
.setPageSize(15)
.setBufferPages(2)
.build());
this.unreadPaymentsLiveData = new UnreadPaymentsLiveData();
this.observer = () -> {
updateDebouncer.publish(() -> {
if (!TextUtils.isEmpty(activeQuery)) {
onSearchQueryUpdated(activeQuery);
@@ -137,6 +143,12 @@ class ConversationListViewModel extends ViewModel {
return pagedData.getController();
}
@NonNull LiveData<List<NotificationProfile>> getNotificationProfiles() {
final Observable<List<NotificationProfile>> activeProfile = Observable.combineLatest(Observable.interval(0, 30, TimeUnit.SECONDS), notificationProfilesRepository.getProfiles(), (interval, profiles) -> profiles);
return LiveDataReactiveStreams.fromPublisher(activeProfile.toFlowable(BackpressureStrategy.LATEST));
}
@NonNull LiveData<WebSocketConnectionState> getPipeState() {
return LiveDataReactiveStreams.fromPublisher(ApplicationDependencies.getSignalWebSocket().getWebSocketState().toFlowable(BackpressureStrategy.LATEST));
}

View File

@@ -55,3 +55,5 @@ fun Cursor.optionalBlob(column: String): Optional<ByteArray> {
fun Cursor.isNull(column: String): Boolean {
return CursorUtil.isNull(this, column)
}
fun Boolean.toInt(): Int = if (this) 1 else 0

View File

@@ -4,6 +4,7 @@ import android.app.Application;
import androidx.annotation.NonNull;
import org.jetbrains.annotations.NotNull;
import org.signal.core.util.concurrent.SignalExecutors;
import org.thoughtcrime.securesms.database.model.MessageId;
import org.thoughtcrime.securesms.util.concurrent.SerialExecutor;
@@ -20,7 +21,7 @@ import java.util.concurrent.Executor;
*
* A replacement for the observer system in {@link Database}. We should move to this over time.
*/
public final class DatabaseObserver {
public class DatabaseObserver {
private final Application application;
private final Executor executor;
@@ -36,6 +37,7 @@ public final class DatabaseObserver {
private final Set<Observer> attachmentObservers;
private final Set<MessageObserver> messageUpdateObservers;
private final Map<Long, Set<MessageObserver>> messageInsertObservers;
private final Set<Observer> notificationProfileObservers;
public DatabaseObserver(Application application) {
this.application = application;
@@ -51,6 +53,7 @@ public final class DatabaseObserver {
this.attachmentObservers = new HashSet<>();
this.messageUpdateObservers = new HashSet<>();
this.messageInsertObservers = new HashMap<>();
this.notificationProfileObservers = new HashSet<>();
}
public void registerConversationListObserver(@NonNull Observer listener) {
@@ -119,6 +122,12 @@ public final class DatabaseObserver {
});
}
public void registerNotificationProfileObserver(@NotNull Observer listener) {
executor.execute(() -> {
notificationProfileObservers.add(listener);
});
}
public void unregisterObserver(@NonNull Observer listener) {
executor.execute(() -> {
conversationListObservers.remove(listener);
@@ -129,6 +138,7 @@ public final class DatabaseObserver {
stickerObservers.remove(listener);
stickerPackObservers.remove(listener);
attachmentObservers.remove(listener);
notificationProfileObservers.remove(listener);
});
}
@@ -231,6 +241,12 @@ public final class DatabaseObserver {
});
}
public void notifyNotificationProfileObservers() {
executor.execute(() -> {
notifySet(notificationProfileObservers);
});
}
private <K, V> void registerMapped(@NonNull Map<K, Set<V>> map, @NonNull K key, @NonNull V listener) {
Set<V> listeners = map.get(key);

View File

@@ -0,0 +1,388 @@
package org.thoughtcrime.securesms.database
import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import net.zetetic.database.sqlcipher.SQLiteConstraintException
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileSchedule
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.SqlUtil
import java.time.DayOfWeek
/**
* Database for maintaining Notification Profiles, Notification Profile Schedules, and Notification Profile allowed memebers.
*/
class NotificationProfileDatabase(context: Context, databaseHelper: SignalDatabase) : Database(context, databaseHelper) {
companion object {
@JvmField
val CREATE_TABLE: Array<String> = arrayOf(NotificationProfileTable.CREATE_TABLE, NotificationProfileScheduleTable.CREATE_TABLE, NotificationProfileAllowedMembersTable.CREATE_TABLE)
@JvmField
val CREATE_INDEXES: Array<String> = arrayOf(NotificationProfileScheduleTable.CREATE_INDEX, NotificationProfileAllowedMembersTable.CREATE_INDEX)
}
private object NotificationProfileTable {
const val TABLE_NAME = "notification_profile"
const val ID = "_id"
const val NAME = "name"
const val EMOJI = "emoji"
const val COLOR = "color"
const val CREATED_AT = "created_at"
const val ALLOW_ALL_CALLS = "allow_all_calls"
const val ALLOW_ALL_MENTIONS = "allow_all_mentions"
val CREATE_TABLE = """
CREATE TABLE $TABLE_NAME (
$ID INTEGER PRIMARY KEY AUTOINCREMENT,
$NAME TEXT NOT NULL UNIQUE,
$EMOJI TEXT NOT NULL,
$COLOR TEXT NOT NULL,
$CREATED_AT INTEGER NOT NULL,
$ALLOW_ALL_CALLS INTEGER NOT NULL DEFAULT 0,
$ALLOW_ALL_MENTIONS INTEGER NOT NULL DEFAULT 0
)
""".trimIndent()
}
private object NotificationProfileScheduleTable {
const val TABLE_NAME = "notification_profile_schedule"
const val ID = "_id"
const val NOTIFICATION_PROFILE_ID = "notification_profile_id"
const val ENABLED = "enabled"
const val START = "start"
const val END = "end"
const val DAYS_ENABLED = "days_enabled"
val DEFAULT_DAYS = listOf(DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY, DayOfWeek.THURSDAY, DayOfWeek.FRIDAY).serialize()
val CREATE_TABLE = """
CREATE TABLE $TABLE_NAME (
$ID INTEGER PRIMARY KEY AUTOINCREMENT,
$NOTIFICATION_PROFILE_ID INTEGER NOT NULL REFERENCES ${NotificationProfileTable.TABLE_NAME} (${NotificationProfileTable.ID}) ON DELETE CASCADE,
$ENABLED INTEGER NOT NULL DEFAULT 0,
$START INTEGER NOT NULL,
$END INTEGER NOT NULL,
$DAYS_ENABLED TEXT NOT NULL
)
""".trimIndent()
const val CREATE_INDEX = "CREATE INDEX notification_profile_schedule_profile_index ON $TABLE_NAME ($NOTIFICATION_PROFILE_ID)"
}
private object NotificationProfileAllowedMembersTable {
const val TABLE_NAME = "notification_profile_allowed_members"
const val ID = "_id"
const val NOTIFICATION_PROFILE_ID = "notification_profile_id"
const val RECIPIENT_ID = "recipient_id"
val CREATE_TABLE = """
CREATE TABLE $TABLE_NAME (
$ID INTEGER PRIMARY KEY AUTOINCREMENT,
$NOTIFICATION_PROFILE_ID INTEGER NOT NULL REFERENCES ${NotificationProfileTable.TABLE_NAME} (${NotificationProfileTable.ID}) ON DELETE CASCADE,
$RECIPIENT_ID INTEGER NOT NULL,
UNIQUE($NOTIFICATION_PROFILE_ID, $RECIPIENT_ID) ON CONFLICT REPLACE
)
""".trimIndent()
const val CREATE_INDEX = "CREATE INDEX notification_profile_allowed_members_profile_index ON $TABLE_NAME ($NOTIFICATION_PROFILE_ID)"
}
fun createProfile(name: String, emoji: String, color: AvatarColor, createdAt: Long): NotificationProfileChangeResult {
val db = writableDatabase
db.beginTransaction()
try {
val profileValues = ContentValues().apply {
put(NotificationProfileTable.NAME, name)
put(NotificationProfileTable.EMOJI, emoji)
put(NotificationProfileTable.COLOR, color.serialize())
put(NotificationProfileTable.CREATED_AT, createdAt)
}
val profileId = db.insert(NotificationProfileTable.TABLE_NAME, null, profileValues)
if (profileId < 0) {
return NotificationProfileChangeResult.DuplicateName
}
val scheduleValues = ContentValues().apply {
put(NotificationProfileScheduleTable.NOTIFICATION_PROFILE_ID, profileId)
put(NotificationProfileScheduleTable.START, 900)
put(NotificationProfileScheduleTable.END, 1700)
put(NotificationProfileScheduleTable.DAYS_ENABLED, NotificationProfileScheduleTable.DEFAULT_DAYS)
}
db.insert(NotificationProfileScheduleTable.TABLE_NAME, null, scheduleValues)
db.setTransactionSuccessful()
return NotificationProfileChangeResult.Success(
NotificationProfile(
id = profileId,
name = name,
emoji = emoji,
createdAt = createdAt,
schedule = getProfileSchedule(profileId)
)
)
} finally {
db.endTransaction()
ApplicationDependencies.getDatabaseObserver().notifyNotificationProfileObservers()
}
}
fun updateProfile(profileId: Long, name: String, emoji: String): NotificationProfileChangeResult {
val profileValues = ContentValues().apply {
put(NotificationProfileTable.NAME, name)
put(NotificationProfileTable.EMOJI, emoji)
}
val updateQuery = SqlUtil.buildTrueUpdateQuery(ID_WHERE, SqlUtil.buildArgs(profileId), profileValues)
return try {
val count = writableDatabase.update(NotificationProfileTable.TABLE_NAME, profileValues, updateQuery.where, updateQuery.whereArgs)
if (count > 0) {
ApplicationDependencies.getDatabaseObserver().notifyNotificationProfileObservers()
}
NotificationProfileChangeResult.Success(getProfile(profileId)!!)
} catch (e: SQLiteConstraintException) {
NotificationProfileChangeResult.DuplicateName
}
}
fun updateProfile(profile: NotificationProfile): NotificationProfileChangeResult {
val db = writableDatabase
db.beginTransaction()
try {
val profileValues = ContentValues().apply {
put(NotificationProfileTable.NAME, profile.name)
put(NotificationProfileTable.EMOJI, profile.emoji)
put(NotificationProfileTable.ALLOW_ALL_CALLS, profile.allowAllCalls.toInt())
put(NotificationProfileTable.ALLOW_ALL_MENTIONS, profile.allowAllMentions.toInt())
}
val updateQuery = SqlUtil.buildTrueUpdateQuery(ID_WHERE, SqlUtil.buildArgs(profile.id), profileValues)
try {
db.update(NotificationProfileTable.TABLE_NAME, profileValues, updateQuery.where, updateQuery.whereArgs)
} catch (e: SQLiteConstraintException) {
return NotificationProfileChangeResult.DuplicateName
}
updateSchedule(profile.schedule, true)
db.delete(NotificationProfileAllowedMembersTable.TABLE_NAME, "${NotificationProfileAllowedMembersTable.NOTIFICATION_PROFILE_ID} = ?", SqlUtil.buildArgs(profile.id))
profile.allowedMembers.forEach { recipientId ->
val allowedMembersValues = ContentValues().apply {
put(NotificationProfileAllowedMembersTable.NOTIFICATION_PROFILE_ID, profile.id)
put(NotificationProfileAllowedMembersTable.RECIPIENT_ID, recipientId.serialize())
}
db.insert(NotificationProfileAllowedMembersTable.TABLE_NAME, null, allowedMembersValues)
}
db.setTransactionSuccessful()
return NotificationProfileChangeResult.Success(getProfile(profile.id)!!)
} finally {
db.endTransaction()
ApplicationDependencies.getDatabaseObserver().notifyNotificationProfileObservers()
}
}
fun updateSchedule(schedule: NotificationProfileSchedule, silent: Boolean = false) {
val scheduleValues = ContentValues().apply {
put(NotificationProfileScheduleTable.ENABLED, schedule.enabled.toInt())
put(NotificationProfileScheduleTable.START, schedule.start)
put(NotificationProfileScheduleTable.END, schedule.end)
put(NotificationProfileScheduleTable.DAYS_ENABLED, schedule.daysEnabled.serialize())
}
val updateQuery = SqlUtil.buildTrueUpdateQuery(ID_WHERE, SqlUtil.buildArgs(schedule.id), scheduleValues)
writableDatabase.update(NotificationProfileScheduleTable.TABLE_NAME, scheduleValues, updateQuery.where, updateQuery.whereArgs)
if (!silent) {
ApplicationDependencies.getDatabaseObserver().notifyNotificationProfileObservers()
}
}
fun setAllowedRecipients(profileId: Long, recipients: Set<RecipientId>): NotificationProfile {
val db = writableDatabase
db.beginTransaction()
try {
db.delete(NotificationProfileAllowedMembersTable.TABLE_NAME, "${NotificationProfileAllowedMembersTable.NOTIFICATION_PROFILE_ID} = ?", SqlUtil.buildArgs(profileId))
recipients.forEach { recipientId ->
val allowedMembersValues = ContentValues().apply {
put(NotificationProfileAllowedMembersTable.NOTIFICATION_PROFILE_ID, profileId)
put(NotificationProfileAllowedMembersTable.RECIPIENT_ID, recipientId.serialize())
}
db.insert(NotificationProfileAllowedMembersTable.TABLE_NAME, null, allowedMembersValues)
}
db.setTransactionSuccessful()
return getProfile(profileId)!!
} finally {
db.endTransaction()
ApplicationDependencies.getDatabaseObserver().notifyNotificationProfileObservers()
}
}
fun addAllowedRecipient(profileId: Long, recipientId: RecipientId): NotificationProfile {
val allowedValues = ContentValues().apply {
put(NotificationProfileAllowedMembersTable.NOTIFICATION_PROFILE_ID, profileId)
put(NotificationProfileAllowedMembersTable.RECIPIENT_ID, recipientId.serialize())
}
writableDatabase.insert(NotificationProfileAllowedMembersTable.TABLE_NAME, null, allowedValues)
ApplicationDependencies.getDatabaseObserver().notifyNotificationProfileObservers()
return getProfile(profileId)!!
}
fun removeAllowedRecipient(profileId: Long, recipientId: RecipientId): NotificationProfile {
writableDatabase.delete(
NotificationProfileAllowedMembersTable.TABLE_NAME,
"${NotificationProfileAllowedMembersTable.NOTIFICATION_PROFILE_ID} = ? AND ${NotificationProfileAllowedMembersTable.RECIPIENT_ID} = ?",
SqlUtil.buildArgs(profileId, recipientId)
)
ApplicationDependencies.getDatabaseObserver().notifyNotificationProfileObservers()
return getProfile(profileId)!!
}
fun getProfiles(): List<NotificationProfile> {
val profiles: MutableList<NotificationProfile> = mutableListOf()
readableDatabase.query(NotificationProfileTable.TABLE_NAME, null, null, null, null, null, null).use { cursor ->
while (cursor.moveToNext()) {
profiles += getProfile(cursor)
}
}
return profiles
}
fun getProfile(profileId: Long): NotificationProfile? {
return readableDatabase.query(NotificationProfileTable.TABLE_NAME, null, ID_WHERE, SqlUtil.buildArgs(profileId), null, null, null).use { cursor ->
if (cursor.moveToFirst()) {
getProfile(cursor)
} else {
null
}
}
}
fun deleteProfile(profileId: Long) {
writableDatabase.delete(NotificationProfileTable.TABLE_NAME, ID_WHERE, SqlUtil.buildArgs(profileId))
ApplicationDependencies.getDatabaseObserver().notifyNotificationProfileObservers()
}
fun remapRecipient(oldId: RecipientId, newId: RecipientId) {
val query = "${NotificationProfileAllowedMembersTable.RECIPIENT_ID} = ?"
val args = SqlUtil.buildArgs(oldId)
val values = ContentValues().apply {
put(NotificationProfileAllowedMembersTable.RECIPIENT_ID, newId.serialize())
}
databaseHelper.signalWritableDatabase.update(NotificationProfileAllowedMembersTable.TABLE_NAME, values, query, args)
ApplicationDependencies.getDatabaseObserver().notifyNotificationProfileObservers()
}
private fun getProfile(cursor: Cursor): NotificationProfile {
val profileId: Long = cursor.requireLong(NotificationProfileTable.ID)
return NotificationProfile(
id = profileId,
name = cursor.requireString(NotificationProfileTable.NAME)!!,
emoji = cursor.requireString(NotificationProfileTable.EMOJI)!!,
color = AvatarColor.deserialize(cursor.requireString(NotificationProfileTable.COLOR)),
createdAt = cursor.requireLong(NotificationProfileTable.CREATED_AT),
allowAllCalls = cursor.requireBoolean(NotificationProfileTable.ALLOW_ALL_CALLS),
allowAllMentions = cursor.requireBoolean(NotificationProfileTable.ALLOW_ALL_MENTIONS),
schedule = getProfileSchedule(profileId),
allowedMembers = getProfileAllowedMembers(profileId)
)
}
private fun getProfileSchedule(profileId: Long): NotificationProfileSchedule {
val query = SqlUtil.buildQuery("${NotificationProfileScheduleTable.NOTIFICATION_PROFILE_ID} = ?", profileId)
return readableDatabase.query(NotificationProfileScheduleTable.TABLE_NAME, null, query.where, query.whereArgs, null, null, null).use { cursor ->
if (cursor.moveToFirst()) {
val daysEnabledString = cursor.requireString(NotificationProfileScheduleTable.DAYS_ENABLED) ?: ""
val daysEnabled: Set<DayOfWeek> = daysEnabledString.split(",")
.filter { it.isNotBlank() }
.map { it.toDayOfWeek() }
.toSet()
NotificationProfileSchedule(
id = cursor.requireLong(NotificationProfileScheduleTable.ID),
enabled = cursor.requireBoolean(NotificationProfileScheduleTable.ENABLED),
start = cursor.requireInt(NotificationProfileScheduleTable.START),
end = cursor.requireInt(NotificationProfileScheduleTable.END),
daysEnabled = daysEnabled
)
} else {
throw AssertionError("No schedule for $profileId")
}
}
}
private fun getProfileAllowedMembers(profileId: Long): Set<RecipientId> {
val allowed = mutableSetOf<RecipientId>()
val query = SqlUtil.buildQuery("${NotificationProfileAllowedMembersTable.NOTIFICATION_PROFILE_ID} = ?", profileId)
readableDatabase.query(NotificationProfileAllowedMembersTable.TABLE_NAME, null, query.where, query.whereArgs, null, null, null).use { cursor ->
while (cursor.moveToNext()) {
allowed += RecipientId.from(cursor.requireLong(NotificationProfileAllowedMembersTable.RECIPIENT_ID))
}
}
return allowed
}
sealed class NotificationProfileChangeResult {
data class Success(val notificationProfile: NotificationProfile) : NotificationProfileChangeResult()
object DuplicateName : NotificationProfileChangeResult()
}
}
private fun Iterable<DayOfWeek>.serialize(): String {
return joinToString(separator = ",", transform = { it.serialize() })
}
private fun String.toDayOfWeek(): DayOfWeek {
return when (this) {
"1" -> DayOfWeek.MONDAY
"2" -> DayOfWeek.TUESDAY
"3" -> DayOfWeek.WEDNESDAY
"4" -> DayOfWeek.THURSDAY
"5" -> DayOfWeek.FRIDAY
"6" -> DayOfWeek.SATURDAY
"7" -> DayOfWeek.SUNDAY
else -> throw AssertionError("Value ($this) does not map to a day")
}
}
private fun DayOfWeek.serialize(): String {
return when (this) {
DayOfWeek.MONDAY -> "1"
DayOfWeek.TUESDAY -> "2"
DayOfWeek.WEDNESDAY -> "3"
DayOfWeek.THURSDAY -> "4"
DayOfWeek.FRIDAY -> "5"
DayOfWeek.SATURDAY -> "6"
DayOfWeek.SUNDAY -> "7"
}
}

View File

@@ -31,6 +31,7 @@ import org.thoughtcrime.securesms.database.IdentityDatabase.VerifiedStatus
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.groups
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.identities
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.messageLog
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.notificationProfiles
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.reactions
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.sessions
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.threads
@@ -2592,6 +2593,9 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
// Reactions
reactions.remapRecipient(byE164, byAci)
// Notification Profiles
notificationProfiles.remapRecipient(byE164, byAci)
// Recipient
Log.w(TAG, "Deleting recipient $byE164", true)
db.delete(TABLE_NAME, ID_WHERE, SqlUtil.buildArgs(byE164))

View File

@@ -70,6 +70,7 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
val avatarPickerDatabase: AvatarPickerDatabase = AvatarPickerDatabase(context, this)
val groupCallRingDatabase: GroupCallRingDatabase = GroupCallRingDatabase(context, this)
val reactionDatabase: ReactionDatabase = ReactionDatabase(context, this)
val notificationProfileDatabase: NotificationProfileDatabase = NotificationProfileDatabase(context, this)
override fun onOpen(db: net.zetetic.database.sqlcipher.SQLiteDatabase) {
db.enableWriteAheadLogging()
@@ -105,6 +106,8 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
executeStatements(db, SearchDatabase.CREATE_TABLE)
executeStatements(db, RemappedRecordsDatabase.CREATE_TABLE)
executeStatements(db, MessageSendLogDatabase.CREATE_TABLE)
executeStatements(db, NotificationProfileDatabase.CREATE_TABLE)
executeStatements(db, RecipientDatabase.CREATE_INDEXS)
executeStatements(db, SmsDatabase.CREATE_INDEXS)
executeStatements(db, MmsDatabase.CREATE_INDEXS)
@@ -119,8 +122,11 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
executeStatements(db, PaymentDatabase.CREATE_INDEXES)
executeStatements(db, MessageSendLogDatabase.CREATE_INDEXES)
executeStatements(db, GroupCallRingDatabase.CREATE_INDEXES)
executeStatements(db, NotificationProfileDatabase.CREATE_INDEXES)
executeStatements(db, MessageSendLogDatabase.CREATE_TRIGGERS)
executeStatements(db, ReactionDatabase.CREATE_TRIGGERS)
if (context.getDatabasePath(ClassicOpenHelper.NAME).exists()) {
val legacyHelper = ClassicOpenHelper(context)
val legacyDb = legacyHelper.writableDatabase
@@ -438,5 +444,10 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
@get:JvmName("unknownStorageIds")
val unknownStorageIds: UnknownStorageIdDatabase
get() = instance!!.storageIdDatabase
@get:JvmStatic
@get:JvmName("notificationProfiles")
val notificationProfiles: NotificationProfileDatabase
get() = instance!!.notificationProfileDatabase
}
}

View File

@@ -179,8 +179,9 @@ object SignalDatabaseMigrations {
private const val SENDER_KEY_SHARED_TIMESTAMP = 120
private const val REACTION_REFACTOR = 121
private const val PNI = 122
private const val NOTIFICATION_PROFILES = 123
const val DATABASE_VERSION = 122
const val DATABASE_VERSION = 123
@JvmStatic
fun migrate(context: Context, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
@@ -2176,6 +2177,51 @@ object SignalDatabaseMigrations {
db.execSQL("ALTER TABLE recipient ADD COLUMN pni TEXT DEFAULT NULL")
db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS recipient_pni_index ON recipient (pni)")
}
if (oldVersion < NOTIFICATION_PROFILES) {
db.execSQL(
// language=sql
"""
CREATE TABLE notification_profile (
_id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
emoji TEXT NOT NULL,
color TEXT NOT NULL,
created_at INTEGER NOT NULL,
allow_all_calls INTEGER NOT NULL DEFAULT 0,
allow_all_mentions INTEGER NOT NULL DEFAULT 0
)
""".trimIndent()
)
db.execSQL(
// language=sql
"""
CREATE TABLE notification_profile_schedule (
_id INTEGER PRIMARY KEY AUTOINCREMENT,
notification_profile_id INTEGER NOT NULL REFERENCES notification_profile (_id) ON DELETE CASCADE,
enabled INTEGER NOT NULL DEFAULT 0,
start INTEGER NOT NULL,
end INTEGER NOT NULL,
days_enabled TEXT NOT NULL
)
""".trimIndent()
)
db.execSQL(
// language=sql
"""
CREATE TABLE notification_profile_allowed_members (
_id INTEGER PRIMARY KEY AUTOINCREMENT,
notification_profile_id INTEGER NOT NULL REFERENCES notification_profile (_id) ON DELETE CASCADE,
recipient_id INTEGER NOT NULL,
UNIQUE(notification_profile_id, recipient_id) ON CONFLICT REPLACE)
""".trimIndent()
)
db.execSQL("CREATE INDEX notification_profile_schedule_profile_index ON notification_profile_schedule (notification_profile_id)")
db.execSQL("CREATE INDEX notification_profile_allowed_members_profile_index ON notification_profile_allowed_members (notification_profile_id)")
}
}
@JvmStatic

View File

@@ -0,0 +1,38 @@
package org.thoughtcrime.securesms.keyvalue
import androidx.annotation.VisibleForTesting
/**
* Values for managing enable/disable state and corresponding alerts for Notification Profiles.
*/
internal class NotificationProfileValues(store: KeyValueStore) : SignalStoreValues(store) {
companion object {
private const val KEY_LAST_PROFILE_POPUP = "np.last_profile_popup"
private const val KEY_LAST_PROFILE_POPUP_TIME = "np.last_profile_popup_time"
private const val KEY_SEEN_TOOLTIP = "np.seen_tooltip"
@VisibleForTesting
const val KEY_MANUALLY_ENABLED_PROFILE = "np.manually_enabled_profile"
@VisibleForTesting
const val KEY_MANUALLY_ENABLED_UNTIL = "np.manually_enabled_until"
@VisibleForTesting
const val KEY_MANUALLY_DISABLED_AT = "np.manually_disabled_at"
}
override fun onFirstEverAppLaunch() = Unit
override fun getKeysToIncludeInBackup(): MutableList<String> {
return mutableListOf(KEY_SEEN_TOOLTIP)
}
var manuallyEnabledProfile: Long by longValue(KEY_MANUALLY_ENABLED_PROFILE, 0L)
var manuallyEnabledUntil: Long by longValue(KEY_MANUALLY_ENABLED_UNTIL, 0L)
var manuallyDisabledAt: Long by longValue(KEY_MANUALLY_DISABLED_AT, 0L)
var lastProfilePopup: Long by longValue(KEY_LAST_PROFILE_POPUP, 0L)
var lastProfilePopupTime: Long by longValue(KEY_LAST_PROFILE_POPUP_TIME, 0L)
var hasSeenTooltip: Boolean by booleanValue(KEY_SEEN_TOOLTIP, false)
}

View File

@@ -18,28 +18,29 @@ public final class SignalStore {
private KeyValueStore store;
private final AccountValues accountValues;
private final KbsValues kbsValues;
private final RegistrationValues registrationValues;
private final PinValues pinValues;
private final RemoteConfigValues remoteConfigValues;
private final StorageServiceValues storageServiceValues;
private final UiHints uiHints;
private final TooltipValues tooltipValues;
private final MiscellaneousValues misc;
private final InternalValues internalValues;
private final EmojiValues emojiValues;
private final SettingsValues settingsValues;
private final CertificateValues certificateValues;
private final PhoneNumberPrivacyValues phoneNumberPrivacyValues;
private final OnboardingValues onboardingValues;
private final WallpaperValues wallpaperValues;
private final PaymentsValues paymentsValues;
private final DonationsValues donationsValues;
private final ProxyValues proxyValues;
private final RateLimitValues rateLimitValues;
private final ChatColorsValues chatColorsValues;
private final ImageEditorValues imageEditorValues;
private final AccountValues accountValues;
private final KbsValues kbsValues;
private final RegistrationValues registrationValues;
private final PinValues pinValues;
private final RemoteConfigValues remoteConfigValues;
private final StorageServiceValues storageServiceValues;
private final UiHints uiHints;
private final TooltipValues tooltipValues;
private final MiscellaneousValues misc;
private final InternalValues internalValues;
private final EmojiValues emojiValues;
private final SettingsValues settingsValues;
private final CertificateValues certificateValues;
private final PhoneNumberPrivacyValues phoneNumberPrivacyValues;
private final OnboardingValues onboardingValues;
private final WallpaperValues wallpaperValues;
private final PaymentsValues paymentsValues;
private final DonationsValues donationsValues;
private final ProxyValues proxyValues;
private final RateLimitValues rateLimitValues;
private final ChatColorsValues chatColorsValues;
private final ImageEditorValues imageEditorValues;
private final NotificationProfileValues notificationProfileValues;
private static volatile SignalStore instance;
@@ -56,29 +57,30 @@ public final class SignalStore {
}
private SignalStore(@NonNull KeyValueStore store) {
this.store = store;
this.accountValues = new AccountValues(store);
this.kbsValues = new KbsValues(store);
this.registrationValues = new RegistrationValues(store);
this.pinValues = new PinValues(store);
this.remoteConfigValues = new RemoteConfigValues(store);
this.storageServiceValues = new StorageServiceValues(store);
this.uiHints = new UiHints(store);
this.tooltipValues = new TooltipValues(store);
this.misc = new MiscellaneousValues(store);
this.internalValues = new InternalValues(store);
this.emojiValues = new EmojiValues(store);
this.settingsValues = new SettingsValues(store);
this.certificateValues = new CertificateValues(store);
this.phoneNumberPrivacyValues = new PhoneNumberPrivacyValues(store);
this.onboardingValues = new OnboardingValues(store);
this.wallpaperValues = new WallpaperValues(store);
this.paymentsValues = new PaymentsValues(store);
this.donationsValues = new DonationsValues(store);
this.proxyValues = new ProxyValues(store);
this.rateLimitValues = new RateLimitValues(store);
this.chatColorsValues = new ChatColorsValues(store);
this.imageEditorValues = new ImageEditorValues(store);
this.store = store;
this.accountValues = new AccountValues(store);
this.kbsValues = new KbsValues(store);
this.registrationValues = new RegistrationValues(store);
this.pinValues = new PinValues(store);
this.remoteConfigValues = new RemoteConfigValues(store);
this.storageServiceValues = new StorageServiceValues(store);
this.uiHints = new UiHints(store);
this.tooltipValues = new TooltipValues(store);
this.misc = new MiscellaneousValues(store);
this.internalValues = new InternalValues(store);
this.emojiValues = new EmojiValues(store);
this.settingsValues = new SettingsValues(store);
this.certificateValues = new CertificateValues(store);
this.phoneNumberPrivacyValues = new PhoneNumberPrivacyValues(store);
this.onboardingValues = new OnboardingValues(store);
this.wallpaperValues = new WallpaperValues(store);
this.paymentsValues = new PaymentsValues(store);
this.donationsValues = new DonationsValues(store);
this.proxyValues = new ProxyValues(store);
this.rateLimitValues = new RateLimitValues(store);
this.chatColorsValues = new ChatColorsValues(store);
this.imageEditorValues = new ImageEditorValues(store);
this.notificationProfileValues = new NotificationProfileValues(store);
}
public static void onFirstEverAppLaunch() {
@@ -104,6 +106,7 @@ public final class SignalStore {
rateLimit().onFirstEverAppLaunch();
chatColorsValues().onFirstEverAppLaunch();
imageEditorValues().onFirstEverAppLaunch();
notificationProfileValues().onFirstEverAppLaunch();
}
public static List<String> getKeysToIncludeInBackup() {
@@ -130,6 +133,7 @@ public final class SignalStore {
keys.addAll(rateLimit().getKeysToIncludeInBackup());
keys.addAll(chatColorsValues().getKeysToIncludeInBackup());
keys.addAll(imageEditorValues().getKeysToIncludeInBackup());
keys.addAll(notificationProfileValues().getKeysToIncludeInBackup());
return keys;
}
@@ -230,6 +234,10 @@ public final class SignalStore {
return getInstance().imageEditorValues;
}
public static @NonNull NotificationProfileValues notificationProfileValues() {
return getInstance().notificationProfileValues;
}
public static @NonNull GroupsV2AuthorizationSignalStoreCache groupsV2AuthorizationCache() {
return new GroupsV2AuthorizationSignalStoreCache(getStore());
}

View File

@@ -0,0 +1,104 @@
package org.thoughtcrime.securesms.keyvalue
import kotlin.reflect.KProperty
internal fun SignalStoreValues.longValue(key: String, default: Long): SignalStoreValueDelegate<Long> {
return LongValue(key, default)
}
internal fun SignalStoreValues.booleanValue(key: String, default: Boolean): SignalStoreValueDelegate<Boolean> {
return BooleanValue(key, default)
}
internal fun <T : String?> SignalStoreValues.stringValue(key: String, default: T): SignalStoreValueDelegate<T> {
return StringValue(key, default)
}
internal fun SignalStoreValues.integerValue(key: String, default: Int): SignalStoreValueDelegate<Int> {
return IntValue(key, default)
}
internal fun SignalStoreValues.floatValue(key: String, default: Float): SignalStoreValueDelegate<Float> {
return FloatValue(key, default)
}
internal fun SignalStoreValues.blobValue(key: String, default: ByteArray): SignalStoreValueDelegate<ByteArray> {
return BlobValue(key, default)
}
/**
* Kotlin delegate that serves as a base for all other value types. This allows us to only expose this sealed
* class to callers and protect the individual implementations as private behind the various extension functions.
*/
sealed class SignalStoreValueDelegate<T> {
operator fun getValue(thisRef: Any?, property: KProperty<*>): T {
return getValue(thisRef as SignalStoreValues)
}
operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
setValue(thisRef as SignalStoreValues, value)
}
internal abstract fun getValue(values: SignalStoreValues): T
internal abstract fun setValue(values: SignalStoreValues, value: T)
}
private class LongValue(private val key: String, private val default: Long) : SignalStoreValueDelegate<Long>() {
override fun getValue(values: SignalStoreValues): Long {
return values.getLong(key, default)
}
override fun setValue(values: SignalStoreValues, value: Long) {
values.putLong(key, value)
}
}
private class BooleanValue(private val key: String, private val default: Boolean) : SignalStoreValueDelegate<Boolean>() {
override fun getValue(values: SignalStoreValues): Boolean {
return values.getBoolean(key, default)
}
override fun setValue(values: SignalStoreValues, value: Boolean) {
values.putBoolean(key, value)
}
}
private class StringValue<T : String?>(private val key: String, private val default: T) : SignalStoreValueDelegate<T>() {
override fun getValue(values: SignalStoreValues): T {
return values.getString(key, default) as T
}
override fun setValue(values: SignalStoreValues, value: T) {
values.putString(key, value)
}
}
private class IntValue(private val key: String, private val default: Int) : SignalStoreValueDelegate<Int>() {
override fun getValue(values: SignalStoreValues): Int {
return values.getInteger(key, default)
}
override fun setValue(values: SignalStoreValues, value: Int) {
values.putInteger(key, value)
}
}
private class FloatValue(private val key: String, private val default: Float) : SignalStoreValueDelegate<Float>() {
override fun getValue(values: SignalStoreValues): Float {
return values.getFloat(key, default)
}
override fun setValue(values: SignalStoreValues, value: Float) {
values.putFloat(key, value)
}
}
private class BlobValue(private val key: String, private val default: ByteArray) : SignalStoreValueDelegate<ByteArray>() {
override fun getValue(values: SignalStoreValues): ByteArray {
return values.getBlob(key, default)
}
override fun setValue(values: SignalStoreValues, value: ByteArray) {
values.putBlob(key, value)
}
}

View File

@@ -30,7 +30,6 @@ import org.thoughtcrime.securesms.profiles.AvatarHelper;
import org.thoughtcrime.securesms.profiles.ProfileName;
import org.thoughtcrime.securesms.profiles.manage.ManageProfileActivity;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.LocaleFeatureFlags;
import org.thoughtcrime.securesms.util.PlayServicesUtil;
@@ -110,6 +109,7 @@ public final class Megaphones {
put(Event.CHAT_COLORS, ALWAYS);
put(Event.ADD_A_PROFILE_PHOTO, shouldShowAddAProfilePhotoMegaphone(context) ? ALWAYS : NEVER);
put(Event.BECOME_A_SUSTAINER, shouldShowDonateMegaphone(context) ? ShowForDurationSchedule.showForDays(7) : NEVER);
put(Event.NOTIFICATION_PROFILES, ShowForDurationSchedule.showForDays(7));
}};
}
@@ -141,6 +141,8 @@ public final class Megaphones {
return buildAddAProfilePhotoMegaphone(context);
case BECOME_A_SUSTAINER:
return buildBecomeASustainerMegaphone(context);
case NOTIFICATION_PROFILES:
return buildNotificationProfilesMegaphone(context);
default:
throw new IllegalArgumentException("Event not handled!");
}
@@ -342,6 +344,20 @@ public final class Megaphones {
.build();
}
private static @NonNull Megaphone buildNotificationProfilesMegaphone(@NonNull Context context) {
return new Megaphone.Builder(Event.NOTIFICATION_PROFILES, Megaphone.Style.BASIC)
.setTitle(R.string.NotificationProfilesMegaphone__notification_profiles)
.setImage(R.drawable.ic_notification_profiles_megaphone)
.setBody(R.string.NotificationProfilesMegaphone__only_get_notifications_from_the_people_and_groups_you_choose)
.setActionButton(R.string.NotificationProfilesMegaphone__create_a_profile, (megaphone, listener) -> {
listener.onMegaphoneNavigationRequested(AppSettingsActivity.notificationProfiles(context));
listener.onMegaphoneCompleted(Event.NOTIFICATION_PROFILES);
})
.setSecondaryButton(R.string.NotificationProfilesMegaphone__not_now, (megaphone, listener) -> {
listener.onMegaphoneCompleted(Event.NOTIFICATION_PROFILES);
})
.build();
}
private static boolean shouldShowMessageRequestsMegaphone() {
return Recipient.self().getProfileName() == ProfileName.EMPTY;
@@ -420,7 +436,8 @@ public final class Megaphones {
NOTIFICATIONS("notifications"),
CHAT_COLORS("chat_colors"),
ADD_A_PROFILE_PHOTO("add_a_profile_photo"),
BECOME_A_SUSTAINER("become_a_sustainer");
BECOME_A_SUSTAINER("become_a_sustainer"),
NOTIFICATION_PROFILES("notification_profiles");
private final String key;

View File

@@ -0,0 +1,25 @@
package org.thoughtcrime.securesms.notifications.profiles
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.recipients.RecipientId
data class NotificationProfile(
val id: Long,
val name: String,
val emoji: String,
val color: AvatarColor = AvatarColor.A210,
val createdAt: Long,
val allowAllCalls: Boolean = false,
val allowAllMentions: Boolean = false,
val schedule: NotificationProfileSchedule,
val allowedMembers: Set<RecipientId> = emptySet()
) : Comparable<NotificationProfile> {
fun isRecipientAllowed(id: RecipientId): Boolean {
return allowedMembers.contains(id)
}
override fun compareTo(other: NotificationProfile): Int {
return createdAt.compareTo(other.createdAt)
}
}

View File

@@ -0,0 +1,73 @@
package org.thoughtcrime.securesms.notifications.profiles
import org.thoughtcrime.securesms.util.isBetween
import org.thoughtcrime.securesms.util.toLocalDateTime
import java.time.DayOfWeek
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.ZoneId
/**
* Encapsulate when a notification should be active based on days of the week, start time,
* and end times.
*
* Examples:
*
* start: 9am end: 5pm daysEnabled: Monday would return true for times between Monday 9am and Monday 5pm
* start: 9pm end: 5am daysEnabled: Monday would return true for times between Monday 9pm and Tuesday 5am
* start: 12am end: 12am daysEnabled: Monday would return true for times between Monday 12am and Monday 11:59:59pm
*/
data class NotificationProfileSchedule(
val id: Long,
val enabled: Boolean = false,
val start: Int = 900,
val end: Int = 1700,
val daysEnabled: Set<DayOfWeek> = emptySet()
) {
@JvmOverloads
fun isCurrentlyActive(now: Long, zoneId: ZoneId = ZoneId.systemDefault()): Boolean {
if (!enabled) {
return false
}
return coversTime(now, zoneId)
}
@JvmOverloads
fun coversTime(time: Long, zoneId: ZoneId = ZoneId.systemDefault()): Boolean {
val localNow: LocalDateTime = time.toLocalDateTime(zoneId)
val localStart: LocalDateTime = start.toLocalDateTime(localNow)
val localEnd: LocalDateTime = end.toLocalDateTime(localNow)
return if (end < start) {
(daysEnabled.contains(localStart.dayOfWeek.minus(1)) && localNow.isBetween(localStart.minusDays(1), localEnd)) ||
(daysEnabled.contains(localStart.dayOfWeek) && localNow.isBetween(localStart, localEnd.plusDays(1)))
} else {
daysEnabled.contains(localStart.dayOfWeek) && localNow.isBetween(localStart, localEnd)
}
}
fun startTime(): LocalTime {
return LocalTime.of(start / 100, start % 100)
}
fun startDateTime(now: LocalDateTime): LocalDateTime {
return start.toLocalDateTime(now)
}
fun endTime(): LocalTime {
return LocalTime.of(end / 100, end % 100)
}
fun endDateTime(now: LocalDateTime): LocalDateTime {
return end.toLocalDateTime(now)
}
}
fun Int.toLocalDateTime(now: LocalDateTime): LocalDateTime {
if (this == 2400) {
return now.plusDays(1).withHour(0)
}
return now.withHour(this / 100).withMinute(this % 100)
}

View File

@@ -0,0 +1,64 @@
package org.thoughtcrime.securesms.notifications.profiles
import android.content.Context
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.keyvalue.NotificationProfileValues
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.formatHours
import org.thoughtcrime.securesms.util.toLocalDateTime
import org.thoughtcrime.securesms.util.toLocalTime
import org.thoughtcrime.securesms.util.toMillis
import java.time.LocalDateTime
import java.time.ZoneId
/**
* Helper for determining the single, currently active Notification Profile (if any) and also how to describe
* how long the active profile will be on for.
*/
object NotificationProfiles {
@JvmStatic
@JvmOverloads
fun getActiveProfile(profiles: List<NotificationProfile>, now: Long = System.currentTimeMillis(), zoneId: ZoneId = ZoneId.systemDefault()): NotificationProfile? {
val storeValues: NotificationProfileValues = SignalStore.notificationProfileValues()
val localNow: LocalDateTime = now.toLocalDateTime(zoneId)
val manualProfile: NotificationProfile? = profiles.firstOrNull { it.id == storeValues.manuallyEnabledProfile }
val scheduledProfile: NotificationProfile? = profiles.sortedDescending().filter { it.schedule.isCurrentlyActive(now, zoneId) }.firstOrNull { profile ->
profile.schedule.startDateTime(localNow).toMillis() > storeValues.manuallyDisabledAt
}
if (manualProfile == null || scheduledProfile == null) {
return (if (now < storeValues.manuallyEnabledUntil) manualProfile else null) ?: scheduledProfile
}
return if (manualProfile == scheduledProfile) {
if (storeValues.manuallyEnabledUntil == Long.MAX_VALUE || now < storeValues.manuallyEnabledUntil) {
manualProfile
} else {
null
}
} else {
scheduledProfile
}
}
fun getActiveProfileDescription(context: Context, profile: NotificationProfile, now: Long = System.currentTimeMillis()): String {
val storeValues: NotificationProfileValues = SignalStore.notificationProfileValues()
if (profile.id == storeValues.manuallyEnabledProfile) {
if (storeValues.manuallyEnabledUntil.isForever()) {
return context.getString(R.string.NotificationProfilesFragment__on)
} else if (now < storeValues.manuallyEnabledUntil) {
return context.getString(R.string.NotificationProfileSelection__on_until_s, storeValues.manuallyEnabledUntil.toLocalTime().formatHours())
}
}
return context.getString(R.string.NotificationProfileSelection__on_until_s, profile.schedule.endTime().formatHours())
}
private fun Long.isForever(): Boolean {
return this == Long.MAX_VALUE
}
}

View File

@@ -10,9 +10,11 @@ import android.os.Build
import android.service.notification.StatusBarNotification
import androidx.appcompat.view.ContextThemeWrapper
import androidx.core.content.ContextCompat
import io.reactivex.rxjava3.kotlin.subscribeBy
import me.leolin.shortcutbadger.ShortcutBadger
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.NotificationProfilesRepository
import org.thoughtcrime.securesms.database.MessageDatabase
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
@@ -22,6 +24,8 @@ import org.thoughtcrime.securesms.notifications.MessageNotifier
import org.thoughtcrime.securesms.notifications.MessageNotifier.ReminderReceiver
import org.thoughtcrime.securesms.notifications.NotificationCancellationHelper
import org.thoughtcrime.securesms.notifications.NotificationIds
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfiles
import org.thoughtcrime.securesms.preferences.widgets.NotificationPrivacyPreference
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.service.KeyCachingService
@@ -48,12 +52,24 @@ class MessageNotifierV2(context: Application) : MessageNotifier {
@Volatile private var previousLockedStatus: Boolean = KeyCachingService.isLocked(context)
@Volatile private var previousPrivacyPreference: NotificationPrivacyPreference = SignalStore.settings().messageNotificationsPrivacy
@Volatile private var previousState: NotificationStateV2 = NotificationStateV2.EMPTY
@Volatile private var notificationProfile: NotificationProfile? = null
@Volatile private var notificationProfileInitialized: Boolean = false
private val threadReminders: MutableMap<Long, Reminder> = ConcurrentHashMap()
private val stickyThreads: MutableMap<Long, StickyThread> = mutableMapOf()
private val executor = CancelableExecutor()
init {
NotificationProfilesRepository().getProfiles()
.subscribeBy(
onNext = {
notificationProfile = NotificationProfiles.getActiveProfile(it)
notificationProfileInitialized = true
}
)
}
override fun setVisibleThread(threadId: Long) {
visibleThread = threadId
stickyThreads.remove(threadId)
@@ -115,10 +131,6 @@ class MessageNotifierV2(context: Application) : MessageNotifier {
reminderCount: Int,
defaultBubbleState: BubbleState
) {
if (!SignalStore.settings().isMessageNotificationsEnabled) {
return
}
val currentLockStatus: Boolean = KeyCachingService.isLocked(context)
val currentPrivacyPreference: NotificationPrivacyPreference = SignalStore.settings().messageNotificationsPrivacy
val notificationConfigurationChanged: Boolean = currentLockStatus != previousLockedStatus || currentPrivacyPreference != previousPrivacyPreference
@@ -129,10 +141,42 @@ class MessageNotifierV2(context: Application) : MessageNotifier {
stickyThreads.clear()
}
if (!notificationProfileInitialized) {
notificationProfile = NotificationProfiles.getActiveProfile(SignalDatabase.notificationProfiles.getProfiles())
notificationProfileInitialized = true
}
Log.internal().i(TAG, "sticky thread: $stickyThreads")
var state: NotificationStateV2 = NotificationStateProvider.constructNotificationState(context, stickyThreads)
var state: NotificationStateV2 = NotificationStateProvider.constructNotificationState(stickyThreads, notificationProfile)
Log.internal().i(TAG, "state: $state")
if (state.muteFilteredMessages.isNotEmpty()) {
Log.i(TAG, "Marking ${state.muteFilteredMessages.size} muted messages as notified to skip notification")
state.muteFilteredMessages.forEach { item ->
val messageDatabase: MessageDatabase = if (item.isMms) SignalDatabase.mms else SignalDatabase.sms
messageDatabase.markAsNotified(item.id)
}
}
if (state.profileFilteredMessages.isNotEmpty()) {
Log.i(TAG, "Marking ${state.profileFilteredMessages.size} profile filtered messages as notified to skip notification")
state.profileFilteredMessages.forEach { item ->
val messageDatabase: MessageDatabase = if (item.isMms) SignalDatabase.mms else SignalDatabase.sms
messageDatabase.markAsNotified(item.id)
}
}
if (!SignalStore.settings().isMessageNotificationsEnabled) {
Log.i(TAG, "Marking ${state.conversations.size} conversations as notified to skip notification")
state.conversations.forEach { conversation ->
conversation.notificationItems.forEach { item ->
val messageDatabase: MessageDatabase = if (item.isMms) SignalDatabase.mms else SignalDatabase.sms
messageDatabase.markAsNotified(item.id)
}
}
return
}
val displayedNotifications: Set<Int>? = ServiceUtil.getNotificationManager(context).getDisplayedNotificationIds().getOrNull()
if (displayedNotifications != null) {
val cleanedUpThreadIds: MutableSet<Long> = mutableSetOf()
@@ -146,7 +190,7 @@ class MessageNotifierV2(context: Application) : MessageNotifier {
}
if (cleanedUpThreadIds.isNotEmpty()) {
Log.i(TAG, "Cleaned up ${cleanedUpThreadIds.size} thread(s) with dangling notifications")
state = NotificationStateV2(state.conversations.filterNot { cleanedUpThreadIds.contains(it.threadId) })
state = state.copy(conversations = state.conversations.filterNot { cleanedUpThreadIds.contains(it.threadId) })
}
}

View File

@@ -1,6 +1,5 @@
package org.thoughtcrime.securesms.notifications.v2
import android.content.Context
import androidx.annotation.WorkerThread
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.database.MmsSmsColumns
@@ -10,9 +9,9 @@ import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.ReactionRecord
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.CursorUtil
import java.lang.IllegalStateException
/**
* Queries the message databases to determine messages that should be in notifications.
@@ -22,7 +21,7 @@ object NotificationStateProvider {
private val TAG = Log.tag(NotificationStateProvider::class.java)
@WorkerThread
fun constructNotificationState(context: Context, stickyThreads: Map<Long, MessageNotifierV2.StickyThread>): NotificationStateV2 {
fun constructNotificationState(stickyThreads: Map<Long, MessageNotifierV2.StickyThread>, notificationProfile: NotificationProfile?): NotificationStateV2 {
val messages: MutableList<NotificationMessage> = mutableListOf()
SignalDatabase.mmsSms.getMessagesForNotificationState(stickyThreads.values).use { unreadMessages ->
@@ -60,18 +59,30 @@ object NotificationStateProvider {
}
val conversations: MutableList<NotificationConversation> = mutableListOf()
val muteFilteredMessages: MutableList<NotificationStateV2.FilteredMessage> = mutableListOf()
val profileFilteredMessages: MutableList<NotificationStateV2.FilteredMessage> = mutableListOf()
messages.groupBy { it.threadId }
.forEach { (threadId, threadMessages) ->
var notificationItems: MutableList<NotificationItemV2> = mutableListOf()
for (notification: NotificationMessage in threadMessages) {
if (notification.includeMessage()) {
notificationItems.add(MessageNotification(notification.threadRecipient, notification.messageRecord))
when (notification.includeMessage(notificationProfile)) {
MessageInclusion.INCLUDE -> notificationItems.add(MessageNotification(notification.threadRecipient, notification.messageRecord))
MessageInclusion.EXCLUDE -> Unit
MessageInclusion.MUTE_FILTERED -> muteFilteredMessages += NotificationStateV2.FilteredMessage(notification.messageRecord.id, notification.messageRecord.isMms)
MessageInclusion.PROFILE_FILTERED -> profileFilteredMessages += NotificationStateV2.FilteredMessage(notification.messageRecord.id, notification.messageRecord.isMms)
}
if (notification.hasUnreadReactions) {
notification.reactions.filter { notification.includeReaction(it) }
.forEach { notificationItems.add(ReactionNotification(notification.threadRecipient, notification.messageRecord, it)) }
notification.reactions.forEach {
when (notification.includeReaction(it, notificationProfile)) {
MessageInclusion.INCLUDE -> notificationItems.add(ReactionNotification(notification.threadRecipient, notification.messageRecord, it))
MessageInclusion.EXCLUDE -> Unit
MessageInclusion.MUTE_FILTERED -> muteFilteredMessages += NotificationStateV2.FilteredMessage(notification.messageRecord.id, notification.messageRecord.isMms)
MessageInclusion.PROFILE_FILTERED -> profileFilteredMessages += NotificationStateV2.FilteredMessage(notification.messageRecord.id, notification.messageRecord.isMms)
}
}
}
}
@@ -86,7 +97,7 @@ object NotificationStateProvider {
}
}
return NotificationStateV2(conversations)
return NotificationStateV2(conversations, muteFilteredMessages, profileFilteredMessages)
}
private data class NotificationMessage(
@@ -101,18 +112,40 @@ object NotificationStateProvider {
) {
private val isUnreadIncoming: Boolean = isUnreadMessage && !messageRecord.isOutgoing
fun includeMessage(): Boolean {
return (isUnreadIncoming || stickyThread) && (threadRecipient.isNotMuted || (threadRecipient.isAlwaysNotifyMentions && messageRecord.hasSelfMention()))
fun includeMessage(notificationProfile: NotificationProfile?): MessageInclusion {
return if (isUnreadIncoming || stickyThread) {
if (threadRecipient.isMuted && (threadRecipient.isDoNotNotifyMentions || !messageRecord.hasSelfMention())) {
MessageInclusion.MUTE_FILTERED
} else if (notificationProfile != null && !notificationProfile.isRecipientAllowed(threadRecipient.id) && !(notificationProfile.allowAllMentions && messageRecord.hasSelfMention())) {
MessageInclusion.PROFILE_FILTERED
} else {
MessageInclusion.INCLUDE
}
} else {
MessageInclusion.EXCLUDE
}
}
fun includeReaction(reaction: ReactionRecord): Boolean {
return reaction.author != Recipient.self().id && messageRecord.isOutgoing && reaction.dateReceived > lastReactionRead && threadRecipient.isNotMuted
fun includeReaction(reaction: ReactionRecord, notificationProfile: NotificationProfile?): MessageInclusion {
return if (threadRecipient.isMuted) {
MessageInclusion.MUTE_FILTERED
} else if (notificationProfile != null && !notificationProfile.isRecipientAllowed(threadRecipient.id)) {
MessageInclusion.PROFILE_FILTERED
} else if (reaction.author != Recipient.self().id && messageRecord.isOutgoing && reaction.dateReceived > lastReactionRead) {
MessageInclusion.INCLUDE
} else {
MessageInclusion.EXCLUDE
}
}
private val Recipient.isNotMuted: Boolean
get() = !isMuted
private val Recipient.isDoNotNotifyMentions: Boolean
get() = mentionSetting == RecipientDatabase.MentionSetting.DO_NOT_NOTIFY
}
private val Recipient.isAlwaysNotifyMentions: Boolean
get() = mentionSetting == RecipientDatabase.MentionSetting.ALWAYS_NOTIFY
private enum class MessageInclusion {
INCLUDE,
EXCLUDE,
MUTE_FILTERED,
PROFILE_FILTERED
}
}

View File

@@ -11,7 +11,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
/**
* Hold all state for notifications for all conversations.
*/
data class NotificationStateV2(val conversations: List<NotificationConversation>) {
data class NotificationStateV2(val conversations: List<NotificationConversation>, val muteFilteredMessages: List<FilteredMessage>, val profileFilteredMessages: List<FilteredMessage>) {
val threadCount: Int = conversations.size
val isEmpty: Boolean = conversations.isEmpty()
@@ -91,7 +91,9 @@ data class NotificationStateV2(val conversations: List<NotificationConversation>
.toSet()
}
data class FilteredMessage(val id: Long, val isMms: Boolean)
companion object {
val EMPTY = NotificationStateV2(emptyList())
val EMPTY = NotificationStateV2(emptyList(), emptyList(), emptyList())
}
}

View File

@@ -9,6 +9,8 @@ import org.thoughtcrime.securesms.components.webrtc.EglBaseWrapper;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.events.WebRtcViewModel;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile;
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfiles;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.ringrtc.RemotePeer;
import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState;
@@ -104,6 +106,18 @@ public class IdleActionProcessor extends WebRtcActionProcessor {
return currentState;
}
NotificationProfile activeProfile = NotificationProfiles.getActiveProfile(SignalDatabase.notificationProfiles().getProfiles());
if (activeProfile != null && !(activeProfile.isRecipientAllowed(remotePeerGroup.getId()) || activeProfile.getAllowAllCalls())) {
try {
Log.i(TAG, "Incoming ring request for profile restricted recipient");
SignalDatabase.groupCallRings().insertOrUpdateGroupRing(ringId, System.currentTimeMillis(), CallManager.RingUpdate.EXPIRED_REQUEST);
webRtcInteractor.getCallManager().cancelGroupRing(groupId.getDecodedId(), ringId, CallManager.RingCancelReason.DeclinedByUser);
} catch (CallException e) {
Log.w(TAG, "Error while trying to cancel ring: " + ringId, e);
}
return currentState;
}
webRtcInteractor.peekGroupCallForRingingCheck(new GroupCallRingCheckInfo(remotePeerGroup.getId(), groupId, ringId, uuid, ringUpdate));
return currentState;

View File

@@ -24,6 +24,8 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.events.CallParticipant;
import org.thoughtcrime.securesms.events.WebRtcViewModel;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile;
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfiles;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.recipients.RecipientUtil;
@@ -187,6 +189,14 @@ public abstract class WebRtcActionProcessor {
return currentState;
}
NotificationProfile activeProfile = NotificationProfiles.getActiveProfile(SignalDatabase.notificationProfiles().getProfiles());
if (activeProfile != null && !(activeProfile.isRecipientAllowed(callMetadata.getRemotePeer().getId()) || activeProfile.getAllowAllCalls())) {
Log.w(tag, "Caller is excluded by notification profile.");
currentState = currentState.getActionProcessor().handleSendHangup(currentState, callMetadata, WebRtcData.HangupMetadata.fromType(HangupMessage.Type.NORMAL), true);
webRtcInteractor.insertMissedCall(callMetadata.getRemotePeer(), receivedOfferMetadata.getServerReceivedTimestamp(), offerMetadata.getOfferType() == OfferMessage.Type.VIDEO_CALL);
return currentState;
}
Log.i(tag, "add remotePeer callId: " + callMetadata.getRemotePeer().getCallId() + " key: " + callMetadata.getRemotePeer().hashCode());
callMetadata.getRemotePeer().setCallStartTimestamp(receivedOfferMetadata.getServerReceivedTimestamp());

View File

@@ -0,0 +1,45 @@
package org.thoughtcrime.securesms.util
import java.time.Instant
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.concurrent.TimeUnit
/**
* Convert [LocalDateTime] to be same as [System.currentTimeMillis]
*/
fun LocalDateTime.toMillis(): Long {
return TimeUnit.SECONDS.toMillis(toEpochSecond(ZoneOffset.UTC))
}
/**
* Return true if the [LocalDateTime] is within [start] and [end] inclusive.
*/
fun LocalDateTime.isBetween(start: LocalDateTime, end: LocalDateTime): Boolean {
return (isEqual(start) || isAfter(start)) && (isEqual(end) || isBefore(end))
}
/**
* Convert milliseconds to local date time with provided [zoneId].
*/
fun Long.toLocalDateTime(zoneId: ZoneId = ZoneId.systemDefault()): LocalDateTime {
return LocalDateTime.ofInstant(Instant.ofEpochMilli(this), zoneId)
}
/**
* Converts milliseconds to local time with provided [zoneId].
*/
fun Long.toLocalTime(zoneId: ZoneId = ZoneId.systemDefault()): LocalTime {
return LocalDateTime.ofInstant(Instant.ofEpochMilli(this), zoneId).toLocalTime()
}
/**
* Formats [LocalTime] as localized time. For example, "8:00 AM"
*/
fun LocalTime.formatHours(): String {
return DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).format(this)
}

View File

@@ -0,0 +1,59 @@
package org.thoughtcrime.securesms.util
import android.os.Build
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.PopupWindow
import android.widget.TextView
import org.thoughtcrime.securesms.R
import java.util.concurrent.TimeUnit
/**
* Show a "toast" like message with text and icon that animates in from the top and then animates out to the top.
*/
class TopToastPopup private constructor(parent: ViewGroup, iconResource: Int, descriptionText: String) : PopupWindow(
LayoutInflater.from(parent.context).inflate(R.layout.top_toast_popup, parent, false),
ViewGroup.LayoutParams.MATCH_PARENT,
ViewUtil.dpToPx(86)
) {
private val icon: ImageView = contentView.findViewById(R.id.top_toast_popup_icon)
private val description: TextView = contentView.findViewById(R.id.top_toast_popup_description)
init {
if (Build.VERSION.SDK_INT >= 21) {
elevation = ViewUtil.dpToPx(8).toFloat()
}
animationStyle = R.style.PopupAnimation
icon.setImageResource(iconResource)
description.text = descriptionText
}
private fun show(parent: ViewGroup) {
showAtLocation(parent, Gravity.TOP or Gravity.START, 0, 0)
measureChild()
update()
contentView.postDelayed({ dismiss() }, DURATION)
}
private fun measureChild() {
contentView.measure(
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
)
}
companion object {
private val DURATION = TimeUnit.SECONDS.toMillis(2)
@JvmStatic
fun show(parent: ViewGroup, icon: Int, description: String): TopToastPopup {
val topToast = TopToastPopup(parent, icon, description)
topToast.show(parent)
return topToast
}
}
}