mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-22 01:40:07 +01:00
Add Notification profiles.
This commit is contained in:
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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))!!
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()))!!
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()))!!
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()))!!
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()))!!
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user