diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4a7a7a300..4c49241bf 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -305,6 +305,11 @@ android:windowSoftInputMode="stateAlwaysHidden" android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/> + + (itemView) { + + private val radioButton: RadioButton = itemView.findViewById(R.id.radio_widget) + + override fun bind(model: RadioPreference) { + super.bind(model) + radioButton.isChecked = model.isChecked + itemView.setOnClickListener { + model.onClick() + } + } +} + class ExternalLinkPreferenceViewHolder(itemView: View) : PreferenceViewHolder(itemView) { override fun bind(model: ExternalLinkPreference) { super.bind(model) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsFragment.kt index 7897d7fcb..a29b6431e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsFragment.kt @@ -4,6 +4,7 @@ import android.os.Build import android.os.Bundle import android.view.View import android.widget.EdgeEffect +import androidx.annotation.LayoutRes import androidx.annotation.MenuRes import androidx.annotation.StringRes import androidx.appcompat.widget.Toolbar @@ -14,8 +15,9 @@ import org.thoughtcrime.securesms.R abstract class DSLSettingsFragment( @StringRes private val titleId: Int, - @MenuRes private val menuId: Int = -1 -) : Fragment(R.layout.dsl_settings_fragment) { + @MenuRes private val menuId: Int = -1, + @LayoutRes layoutId: Int = R.layout.dsl_settings_fragment +) : Fragment(layoutId) { private lateinit var recyclerView: RecyclerView private lateinit var toolbarShadowHelper: ToolbarShadowHelper diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsFragment.kt index 795629af8..947d3fb42 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsFragment.kt @@ -6,12 +6,15 @@ import android.content.Intent import android.text.SpannableStringBuilder import android.text.Spanned import android.text.style.TextAppearanceSpan +import android.view.View import android.view.WindowManager +import android.widget.TextView import android.widget.Toast import androidx.annotation.StringRes import androidx.core.content.ContextCompat import androidx.lifecycle.ViewModelProviders import androidx.navigation.Navigation +import androidx.navigation.fragment.NavHostFragment import androidx.preference.PreferenceManager import com.google.android.material.dialog.MaterialAlertDialogBuilder import mobi.upod.timedurationpicker.TimeDurationPicker @@ -19,10 +22,14 @@ import mobi.upod.timedurationpicker.TimeDurationPickerDialog import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.PassphraseChangeActivity import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.ClickPreference +import org.thoughtcrime.securesms.components.settings.ClickPreferenceViewHolder 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.DSLSettingsText +import org.thoughtcrime.securesms.components.settings.PreferenceModel +import org.thoughtcrime.securesms.components.settings.PreferenceViewHolder import org.thoughtcrime.securesms.components.settings.configure import org.thoughtcrime.securesms.crypto.MasterSecretUtil import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues @@ -30,15 +37,17 @@ import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberL import org.thoughtcrime.securesms.service.KeyCachingService import org.thoughtcrime.securesms.util.CommunicationActions import org.thoughtcrime.securesms.util.ConversationUtil +import org.thoughtcrime.securesms.util.ExpirationUtil import org.thoughtcrime.securesms.util.FeatureFlags +import org.thoughtcrime.securesms.util.MappingAdapter import org.thoughtcrime.securesms.util.ServiceUtil import org.thoughtcrime.securesms.util.SpanUtil import org.thoughtcrime.securesms.util.TextSecurePreferences import java.lang.Integer.max -import java.util.ArrayList -import java.util.LinkedHashMap import java.util.Locale import java.util.concurrent.TimeUnit +import kotlin.collections.ArrayList +import kotlin.collections.LinkedHashMap private val TAG = Log.tag(PrivacySettingsFragment::class.java) @@ -62,6 +71,8 @@ class PrivacySettingsFragment : DSLSettingsFragment(R.string.preferences__privac } override fun bindAdapter(adapter: DSLSettingsAdapter) { + adapter.registerFactory(ValueClickPreference::class.java, MappingAdapter.LayoutFactory(::ValueClickPreferenceViewHolder, R.layout.value_click_preference_item)) + val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) val repository = PrivacySettingsRepository() val factory = PrivacySettingsViewModel.Factory(sharedPreferences, repository) @@ -129,6 +140,23 @@ class PrivacySettingsFragment : DSLSettingsFragment(R.string.preferences__privac dividerPref() + sectionHeaderPref(R.string.PrivacySettingsFragment__disappearing_messages) + + customPref( + ValueClickPreference( + value = DSLSettingsText.from(ExpirationUtil.getExpirationAbbreviatedDisplayValue(requireContext(), state.universalExpireTimer)), + clickPreference = ClickPreference( + title = DSLSettingsText.from(R.string.PrivacySettingsFragment__default_timer_for_new_changes), + summary = DSLSettingsText.from(R.string.PrivacySettingsFragment__set_a_default_disappearing_message_timer_for_all_new_chats_started_by_you), + onClick = { + NavHostFragment.findNavController(this@PrivacySettingsFragment).navigate(R.id.action_privacySettingsFragment_to_disappearingMessagesTimerSelectFragment) + } + ) + ) + ) + + dividerPref() + sectionHeaderPref(R.string.PrivacySettingsFragment__app_security) if (state.isObsoletePasswordEnabled) { @@ -141,7 +169,7 @@ class PrivacySettingsFragment : DSLSettingsFragment(R.string.preferences__privac setTitle(R.string.ApplicationPreferencesActivity_disable_passphrase) setMessage(R.string.ApplicationPreferencesActivity_this_will_permanently_unlock_signal_and_message_notifications) setIcon(R.drawable.ic_warning) - setPositiveButton(R.string.ApplicationPreferencesActivity_disable) { dialog, which -> + setPositiveButton(R.string.ApplicationPreferencesActivity_disable) { _, _ -> MasterSecretUtil.changeMasterSecretPassphrase( activity, KeyCachingService.getMasterSecret(context), @@ -395,4 +423,31 @@ class PrivacySettingsFragment : DSLSettingsFragment(R.string.preferences__privac show() } } + + private class ValueClickPreference( + val value: DSLSettingsText, + val clickPreference: ClickPreference + ) : PreferenceModel( + title = clickPreference.title, + summary = clickPreference.summary, + iconId = clickPreference.iconId, + isEnabled = clickPreference.isEnabled + ) { + override fun areContentsTheSame(newItem: ValueClickPreference): Boolean { + return super.areContentsTheSame(newItem) && + clickPreference == newItem.clickPreference && + value == newItem.value + } + } + + private class ValueClickPreferenceViewHolder(itemView: View) : PreferenceViewHolder(itemView) { + private val clickPreferenceViewHolder = ClickPreferenceViewHolder(itemView) + private val valueText: TextView = findViewById(R.id.value_client_preference_value) + + override fun bind(model: ValueClickPreference) { + super.bind(model) + clickPreferenceViewHolder.bind(model.clickPreference) + valueText.text = model.value.resolve(context) + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsState.kt index 4bab0dd51..14f6e5591 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsState.kt @@ -14,5 +14,6 @@ data class PrivacySettingsState( val incognitoKeyboard: Boolean, val isObsoletePasswordEnabled: Boolean, val isObsoletePasswordTimeoutEnabled: Boolean, - val obsoletePasswordTimeout: Int + val obsoletePasswordTimeout: Int, + val universalExpireTimer: Int ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsViewModel.kt index 63bbe956e..d68d0c148 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsViewModel.kt @@ -24,6 +24,7 @@ class PrivacySettingsViewModel( fun refreshBlockedCount() { repository.getBlockedCount { count -> store.update { it.copy(blockedCount = count) } + refresh() } } @@ -99,7 +100,8 @@ class PrivacySettingsViewModel( findMeByPhoneNumber = SignalStore.phoneNumberPrivacy().phoneNumberListingMode, isObsoletePasswordEnabled = !TextSecurePreferences.isPasswordDisabled(ApplicationDependencies.getApplication()), isObsoletePasswordTimeoutEnabled = TextSecurePreferences.isPassphraseTimeoutEnabled(ApplicationDependencies.getApplication()), - obsoletePasswordTimeout = TextSecurePreferences.getPassphraseTimeoutInterval(ApplicationDependencies.getApplication()) + obsoletePasswordTimeout = TextSecurePreferences.getPassphraseTimeoutInterval(ApplicationDependencies.getApplication()), + universalExpireTimer = SignalStore.settings().universalExpireTimer ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/expire/CustomExpireTimerSelectDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/expire/CustomExpireTimerSelectDialog.kt new file mode 100644 index 000000000..7e8078daf --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/expire/CustomExpireTimerSelectDialog.kt @@ -0,0 +1,52 @@ +package org.thoughtcrime.securesms.components.settings.app.privacy.expire + +import android.app.Dialog +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.ViewModelProvider +import androidx.navigation.fragment.NavHostFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.thoughtcrime.securesms.R + +/** + * Dialog for selecting a custom expire timer value. + */ +class CustomExpireTimerSelectDialog : DialogFragment() { + + private lateinit var viewModel: ExpireTimerSettingsViewModel + private lateinit var selector: CustomExpireTimerSelectorView + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val dialogView: View = LayoutInflater.from(context).inflate(R.layout.custom_expire_timer_select_dialog, null, false) + selector = dialogView.findViewById(R.id.custom_expire_timer_select_dialog_selector) + + val builder = MaterialAlertDialogBuilder(requireContext(), R.style.Signal_ThemeOverlay_Dialog_Rounded) + + return builder.setTitle(R.string.ExpireTimerSettingsFragment__custom_time) + .setView(dialogView) + .setPositiveButton(R.string.ExpireTimerSettingsFragment__set) { _, _ -> + viewModel.select(selector.getTimer()) + } + .setNegativeButton(android.R.string.cancel, null) + .create() + } + + override fun onActivityCreated(savedInstanceState: Bundle?) { + super.onActivityCreated(savedInstanceState) + viewModel = ViewModelProvider(NavHostFragment.findNavController(this).getViewModelStoreOwner(R.id.app_settings_expire_timer)) + .get(ExpireTimerSettingsViewModel::class.java) + + viewModel.state.observe(this) { selector.setTimer(it.currentTimer) } + } + + companion object { + private const val DIALOG_TAG = "CustomTimerSelectDialog" + + fun show(fragmentManager: FragmentManager) { + CustomExpireTimerSelectDialog().show(fragmentManager, DIALOG_TAG) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/expire/CustomExpireTimerSelectorView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/expire/CustomExpireTimerSelectorView.kt new file mode 100644 index 000000000..c5b87cb13 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/expire/CustomExpireTimerSelectorView.kt @@ -0,0 +1,79 @@ +package org.thoughtcrime.securesms.components.settings.app.privacy.expire + +import android.content.Context +import android.util.AttributeSet +import android.view.Gravity +import android.widget.LinearLayout +import android.widget.NumberPicker +import org.thoughtcrime.securesms.R +import java.util.concurrent.TimeUnit + +/** + * Show number pickers for value and units that are valid for expiration timer. + */ +class CustomExpireTimerSelectorView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr) { + + private val valuePicker: NumberPicker + private val unitPicker: NumberPicker + + init { + orientation = HORIZONTAL + gravity = Gravity.CENTER + inflate(context, R.layout.custom_expire_timer_selector_view, this) + + valuePicker = findViewById(R.id.custom_expire_timer_selector_value) + unitPicker = findViewById(R.id.custom_expire_timer_selector_unit) + + valuePicker.minValue = TimerUnit.get(1).minValue + valuePicker.maxValue = TimerUnit.get(1).maxValue + + unitPicker.minValue = 0 + unitPicker.maxValue = 4 + unitPicker.value = 1 + unitPicker.wrapSelectorWheel = false + unitPicker.isLongClickable = false + unitPicker.displayedValues = context.resources.getStringArray(R.array.CustomExpireTimerSelectorView__unit_labels) + unitPicker.setOnValueChangedListener { _, _, newValue -> unitChange(newValue) } + } + + fun setTimer(timer: Int?) { + if (timer == null || timer == 0) { + return + } + + TimerUnit.values() + .find { (timer / it.valueMultiplier) < it.maxValue } + ?.let { timerUnit -> + valuePicker.value = (timer / timerUnit.valueMultiplier).toInt() + unitPicker.value = TimerUnit.values().indexOf(timerUnit) + unitChange(unitPicker.value) + } + } + + fun getTimer(): Int { + return valuePicker.value * TimerUnit.get(unitPicker.value).valueMultiplier.toInt() + } + + private fun unitChange(newValue: Int) { + val timerUnit: TimerUnit = TimerUnit.values()[newValue] + + valuePicker.minValue = timerUnit.minValue + valuePicker.maxValue = timerUnit.maxValue + } + + private enum class TimerUnit(val minValue: Int, val maxValue: Int, val valueMultiplier: Long) { + SECONDS(1, 59, TimeUnit.SECONDS.toSeconds(1)), + MINUTES(1, 59, TimeUnit.MINUTES.toSeconds(1)), + HOURS(1, 23, TimeUnit.HOURS.toSeconds(1)), + DAYS(1, 6, TimeUnit.DAYS.toSeconds(1)), + WEEKS(1, 4, TimeUnit.DAYS.toSeconds(7)); + + companion object { + fun get(value: Int) = values()[value] + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/expire/ExpireTimerSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/expire/ExpireTimerSettingsFragment.kt new file mode 100644 index 000000000..aa4973a68 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/expire/ExpireTimerSettingsFragment.kt @@ -0,0 +1,139 @@ +package org.thoughtcrime.securesms.components.settings.app.privacy.expire + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import android.view.View +import android.widget.Toast +import androidx.lifecycle.ViewModelProvider +import androidx.navigation.fragment.NavHostFragment +import androidx.recyclerview.widget.RecyclerView +import com.dd.CircularProgressButton +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.DSLSettingsText +import org.thoughtcrime.securesms.components.settings.configure +import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason +import org.thoughtcrime.securesms.groups.ui.GroupErrors +import org.thoughtcrime.securesms.util.ExpirationUtil +import org.thoughtcrime.securesms.util.ViewUtil +import org.thoughtcrime.securesms.util.livedata.ProcessState +import org.thoughtcrime.securesms.util.livedata.distinctUntilChanged + +/** + * Depending on the arguments, can be used to set the universal expire timer, set expire timer + * for a individual or group recipient, or select a value and return it via result. + */ +class ExpireTimerSettingsFragment : DSLSettingsFragment( + titleId = R.string.PrivacySettingsFragment__disappearing_messages, + layoutId = R.layout.expire_timer_settings_fragment +) { + + private lateinit var save: CircularProgressButton + private lateinit var viewModel: ExpireTimerSettingsViewModel + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + save = view.findViewById(R.id.timer_select_fragment_save) + save.setOnClickListener { viewModel.save() } + adjustListPaddingForSaveButton(view) + } + + private fun adjustListPaddingForSaveButton(view: View) { + val recycler: RecyclerView = view.findViewById(R.id.recycler) + recycler.setPadding(recycler.paddingLeft, recycler.paddingTop, recycler.paddingRight, ViewUtil.dpToPx(80)) + recycler.clipToPadding = false + } + + override fun bindAdapter(adapter: DSLSettingsAdapter) { + val provider = ViewModelProvider( + NavHostFragment.findNavController(this).getViewModelStoreOwner(R.id.app_settings_expire_timer), + ExpireTimerSettingsViewModel.Factory(requireContext(), arguments.toConfig()) + ) + viewModel = provider.get(ExpireTimerSettingsViewModel::class.java) + + viewModel.state.observe(viewLifecycleOwner) { state -> + adapter.submitList(getConfiguration(state).toMappingModelList()) + } + + viewModel.state.distinctUntilChanged(ExpireTimerSettingsState::saveState).observe(viewLifecycleOwner) { state -> + when (val saveState: ProcessState = state.saveState) { + is ProcessState.Working -> { + save.isClickable = false + save.isIndeterminateProgressMode = true + save.progress = 50 + } + is ProcessState.Success -> { + if (state.isGroupCreate) { + requireActivity().setResult(Activity.RESULT_OK, Intent().putExtra(FOR_RESULT_VALUE, saveState.result)) + } + save.isClickable = false + requireActivity().onNavigateUp() + } + is ProcessState.Failure -> { + val groupChangeFailureReason: GroupChangeFailureReason = saveState.throwable?.let(GroupChangeFailureReason::fromException) ?: GroupChangeFailureReason.OTHER + Toast.makeText(context, GroupErrors.getUserDisplayMessage(groupChangeFailureReason), Toast.LENGTH_LONG).show() + viewModel.resetError() + } + else -> { + save.isClickable = true + save.isIndeterminateProgressMode = false + save.progress = 0 + } + } + } + } + + private fun getConfiguration(state: ExpireTimerSettingsState): DSLConfiguration { + return configure { + textPref( + summary = DSLSettingsText.from( + if (state.isForRecipient) { + R.string.ExpireTimerSettingsFragment__when_enabled_new_messages_sent_and_received_in_this_chat_will_disappear_after_they_have_been_seen + } else { + R.string.ExpireTimerSettingsFragment__when_enabled_new_messages_sent_and_received_in_new_chats_started_by_you_will_disappear_after_they_have_been_seen + } + ) + ) + + val labels: Array = resources.getStringArray(R.array.ExpireTimerSettingsFragment__labels) + val values: Array = resources.getIntArray(R.array.ExpireTimerSettingsFragment__values).toTypedArray() + + var hasCustomValue = true + labels.zip(values).forEach { (label, value) -> + radioPref( + title = DSLSettingsText.from(label), + isChecked = state.currentTimer == value, + onClick = { viewModel.select(value) } + ) + hasCustomValue = hasCustomValue && state.currentTimer != value + } + + radioPref( + title = DSLSettingsText.from(R.string.ExpireTimerSettingsFragment__custom_time), + summary = if (hasCustomValue) DSLSettingsText.from(ExpirationUtil.getExpirationDisplayValue(requireContext(), state.currentTimer)) else null, + isChecked = hasCustomValue, + onClick = { NavHostFragment.findNavController(this@ExpireTimerSettingsFragment).navigate(R.id.action_expireTimerSettingsFragment_to_customExpireTimerSelectDialog) } + ) + } + } + + companion object { + const val FOR_RESULT_VALUE = "for_result_value" + } +} + +private fun Bundle?.toConfig(): ExpireTimerSettingsViewModel.Config { + if (this == null) { + return ExpireTimerSettingsViewModel.Config() + } + + val safeArguments: ExpireTimerSettingsFragmentArgs = ExpireTimerSettingsFragmentArgs.fromBundle(this) + return ExpireTimerSettingsViewModel.Config( + recipientId = safeArguments.recipientId, + forResultMode = safeArguments.forResultMode, + initialValue = safeArguments.initialValue + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/expire/ExpireTimerSettingsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/expire/ExpireTimerSettingsRepository.kt new file mode 100644 index 000000000..85af24ef0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/expire/ExpireTimerSettingsRepository.kt @@ -0,0 +1,53 @@ +package org.thoughtcrime.securesms.components.settings.app.privacy.expire + +import android.content.Context +import androidx.annotation.WorkerThread +import org.signal.core.util.concurrent.SignalExecutors +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.database.DatabaseFactory +import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.groups.GroupChangeException +import org.thoughtcrime.securesms.groups.GroupManager +import org.thoughtcrime.securesms.mms.OutgoingExpirationUpdateMessage +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.sms.MessageSender +import java.io.IOException + +private val TAG: String = Log.tag(ExpireTimerSettingsRepository::class.java) + +/** + * Provide operations to set expire timer for individuals and groups. + */ +class ExpireTimerSettingsRepository(val context: Context) { + + fun setExpiration(recipientId: RecipientId, newExpirationTime: Int, consumer: (Result) -> Unit) { + SignalExecutors.BOUNDED.execute { + val recipient = Recipient.resolved(recipientId) + if (recipient.groupId.isPresent && recipient.groupId.get().isPush) { + try { + GroupManager.updateGroupTimer(context, recipient.groupId.get().requirePush(), newExpirationTime) + consumer.invoke(Result.success(newExpirationTime)) + } catch (e: GroupChangeException) { + Log.w(TAG, e) + consumer.invoke(Result.failure(e)) + } catch (e: IOException) { + Log.w(TAG, e) + consumer.invoke(Result.failure(e)) + } + } else { + DatabaseFactory.getRecipientDatabase(context).setExpireMessages(recipientId, newExpirationTime) + val outgoingMessage = OutgoingExpirationUpdateMessage(Recipient.resolved(recipientId), System.currentTimeMillis(), newExpirationTime * 1000L) + MessageSender.send(context, outgoingMessage, getThreadId(recipientId), false, null) + consumer.invoke(Result.success(newExpirationTime)) + } + } + } + + @WorkerThread + private fun getThreadId(recipientId: RecipientId): Long { + val threadDatabase: ThreadDatabase = DatabaseFactory.getThreadDatabase(context) + val recipient: Recipient = Recipient.resolved(recipientId) + return threadDatabase.getThreadIdFor(recipient) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/expire/ExpireTimerSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/expire/ExpireTimerSettingsState.kt new file mode 100644 index 000000000..732a08307 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/expire/ExpireTimerSettingsState.kt @@ -0,0 +1,14 @@ +package org.thoughtcrime.securesms.components.settings.app.privacy.expire + +import org.thoughtcrime.securesms.util.livedata.ProcessState + +data class ExpireTimerSettingsState( + val initialTimer: Int = 0, + val userSetTimer: Int? = null, + val saveState: ProcessState = ProcessState.Idle(), + val isGroupCreate: Boolean = false, + val isForRecipient: Boolean = isGroupCreate, +) { + val currentTimer: Int + get() = userSetTimer ?: initialTimer +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/expire/ExpireTimerSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/expire/ExpireTimerSettingsViewModel.kt new file mode 100644 index 000000000..45bb12c96 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/expire/ExpireTimerSettingsViewModel.kt @@ -0,0 +1,70 @@ +package org.thoughtcrime.securesms.components.settings.app.privacy.expire + +import android.content.Context +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.util.livedata.ProcessState +import org.thoughtcrime.securesms.util.livedata.Store + +class ExpireTimerSettingsViewModel(val config: Config, private val repository: ExpireTimerSettingsRepository) : ViewModel() { + + private val store = Store(ExpireTimerSettingsState(isGroupCreate = config.forResultMode)) + private val recipientId: RecipientId? = config.recipientId + + val state: LiveData = store.stateLiveData + + init { + if (recipientId != null) { + store.update(Recipient.live(recipientId).liveData) { r, s -> s.copy(initialTimer = r.expireMessages, isForRecipient = true) } + } else { + store.update { it.copy(initialTimer = config.initialValue ?: SignalStore.settings().universalExpireTimer) } + } + } + + fun select(time: Int) { + store.update { it.copy(userSetTimer = time) } + } + + fun save() { + val userSetTimer: Int = store.state.currentTimer + + if (userSetTimer == store.state.initialTimer) { + store.update { it.copy(saveState = ProcessState.Success(userSetTimer)) } + return + } + + store.update { it.copy(saveState = ProcessState.Working()) } + if (recipientId != null) { + repository.setExpiration(recipientId, userSetTimer) { result -> + store.update { it.copy(saveState = ProcessState.fromResult(result)) } + } + } else if (config.forResultMode) { + store.update { it.copy(saveState = ProcessState.Success(userSetTimer)) } + } else { + SignalStore.settings().universalExpireTimer = userSetTimer + store.update { it.copy(saveState = ProcessState.Success(userSetTimer)) } + } + } + + fun resetError() { + store.update { it.copy(saveState = ProcessState.Idle()) } + } + + class Factory(context: Context, private val config: Config) : ViewModelProvider.Factory { + val repository = ExpireTimerSettingsRepository(context.applicationContext) + + override fun create(modelClass: Class): T { + return requireNotNull(modelClass.cast(ExpireTimerSettingsViewModel(config, repository))) + } + } + + data class Config( + val recipientId: RecipientId? = null, + val forResultMode: Boolean = false, + val initialValue: Int? = null + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/dsl.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/dsl.kt index 315af6fec..d35f5df3e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/dsl.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/dsl.kt @@ -56,6 +56,17 @@ class DSLConfiguration { children.add(preference) } + fun radioPref( + title: DSLSettingsText, + summary: DSLSettingsText? = null, + isEnabled: Boolean = true, + isChecked: Boolean, + onClick: () -> Unit + ) { + val preference = RadioPreference(title, summary, isEnabled, isChecked, onClick) + children.add(preference) + } + fun clickPref( title: DSLSettingsText, summary: DSLSettingsText? = null, @@ -175,11 +186,23 @@ class SwitchPreference( } } +class RadioPreference( + title: DSLSettingsText, + summary: DSLSettingsText? = null, + isEnabled: Boolean, + val isChecked: Boolean, + val onClick: () -> Unit +) : PreferenceModel(title = title, summary = summary, isEnabled = isEnabled) { + override fun areContentsTheSame(newItem: RadioPreference): Boolean { + return super.areContentsTheSame(newItem) && isChecked == newItem.isChecked + } +} + class ClickPreference( override val title: DSLSettingsText, - override val summary: DSLSettingsText?, - @DrawableRes override val iconId: Int, - isEnabled: Boolean, + override val summary: DSLSettingsText? = null, + @DrawableRes override val iconId: Int = UNSET, + isEnabled: Boolean = true, val onClick: () -> Unit ) : PreferenceModel(title = title, summary = summary, iconId = iconId, isEnabled = isEnabled) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java index a2ec94d6f..599c2fbae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java @@ -2412,6 +2412,17 @@ public class ConversationActivity extends PassphraseRequiredActivity if (groupCallViewModel != null) { groupCallViewModel.onRecipientChange(recipient); } + + if (this.threadId == -1) { + SimpleTask.run(() -> DatabaseFactory.getThreadDatabase(this).getThreadIdIfExistsFor(recipient.getId()), threadId -> { + if (this.threadId != threadId) { + Log.d(TAG, "Thread id changed via recipient change"); + this.threadId = threadId; + fragment.reload(recipient, this.threadId); + setVisibleThread(this.threadId); + } + }); + } } @Subscribe(threadMode = ThreadMode.MAIN) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java index 9e5e9d407..539388593 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java @@ -50,6 +50,7 @@ import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicyEnforcer; import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Projection; import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.util.CachedInflater; import org.thoughtcrime.securesms.util.DateUtils; import org.thoughtcrime.securesms.util.StickyHeaderDecoration; @@ -374,6 +375,10 @@ public class ConversationAdapter this.pagingController = pagingController; } + public boolean isForRecipientId(@NonNull RecipientId recipientId) { + return recipient.getId().equals(recipientId); + } + void onBindLastSeenViewHolder(StickyHeaderViewHolder viewHolder, int position) { viewHolder.setText(viewHolder.itemView.getContext().getResources().getQuantityString(R.plurals.ConversationAdapter_n_unread_messages, (position + 1), (position + 1))); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationData.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationData.java index 176d7aad6..b3a5283f6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationData.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationData.java @@ -14,6 +14,7 @@ final class ConversationData { private final int jumpToPosition; private final int threadSize; private final MessageRequestData messageRequestData; + private final boolean showUniversalExpireTimerMessage; ConversationData(long threadId, long lastSeen, @@ -22,16 +23,18 @@ final class ConversationData { boolean hasSent, int jumpToPosition, int threadSize, - @NonNull MessageRequestData messageRequestData) + @NonNull MessageRequestData messageRequestData, + boolean showUniversalExpireTimerMessage) { - this.threadId = threadId; - this.lastSeen = lastSeen; - this.lastSeenPosition = lastSeenPosition; - this.lastScrolledPosition = lastScrolledPosition; - this.hasSent = hasSent; - this.jumpToPosition = jumpToPosition; - this.threadSize = threadSize; - this.messageRequestData = messageRequestData; + this.threadId = threadId; + this.lastSeen = lastSeen; + this.lastSeenPosition = lastSeenPosition; + this.lastScrolledPosition = lastScrolledPosition; + this.hasSent = hasSent; + this.jumpToPosition = jumpToPosition; + this.threadSize = threadSize; + this.messageRequestData = messageRequestData; + this.showUniversalExpireTimerMessage = showUniversalExpireTimerMessage; } public long getThreadId() { @@ -74,6 +77,10 @@ final class ConversationData { return messageRequestData; } + public boolean showUniversalExpireTimerMessage() { + return showUniversalExpireTimerMessage; + } + static final class MessageRequestData { private final boolean messageRequestAccepted; diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationDataSource.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationDataSource.java index 427f248a7..8b810d48c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationDataSource.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationDataSource.java @@ -35,17 +35,21 @@ class ConversationDataSource implements PagedDataSource { private final Context context; private final long threadId; private final MessageRequestData messageRequestData; + private final boolean showUniversalExpireTimerUpdate; - ConversationDataSource(@NonNull Context context, long threadId, @NonNull MessageRequestData messageRequestData) { - this.context = context; - this.threadId = threadId; - this.messageRequestData = messageRequestData; + ConversationDataSource(@NonNull Context context, long threadId, @NonNull MessageRequestData messageRequestData, boolean showUniversalExpireTimerUpdate) { + this.context = context; + this.threadId = threadId; + this.messageRequestData = messageRequestData; + this.showUniversalExpireTimerUpdate = showUniversalExpireTimerUpdate; } @Override public int size() { long startTime = System.currentTimeMillis(); - int size = DatabaseFactory.getMmsSmsDatabase(context).getConversationCount(threadId) + (messageRequestData.includeWarningUpdateMessage() ? 1 : 0); + int size = DatabaseFactory.getMmsSmsDatabase(context).getConversationCount(threadId) + + (messageRequestData.includeWarningUpdateMessage() ? 1 : 0) + + (showUniversalExpireTimerUpdate ? 1 : 0); Log.d(TAG, "size() for thread " + threadId + ": " + (System.currentTimeMillis() - startTime) + " ms"); @@ -71,6 +75,10 @@ class ConversationDataSource implements PagedDataSource { records.add(new InMemoryMessageRecord.NoGroupsInCommon(threadId, messageRequestData.isGroup())); } + if (showUniversalExpireTimerUpdate) { + records.add(new InMemoryMessageRecord.UniversalExpireTimerUpdate(threadId)); + } + stopwatch.split("messages"); mentionHelper.fetchMentions(context); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java index ac8efd921..f8e25c7ab 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -183,38 +183,37 @@ public class ConversationFragment extends LoggingFragment { private ConversationFragmentListener listener; - private LiveRecipient recipient; - private long threadId; - private boolean isReacting; - private ActionMode actionMode; - private Locale locale; - private FrameLayout videoContainer; - private RecyclerView list; - private RecyclerView.ItemDecoration lastSeenDecoration; - private RecyclerView.ItemDecoration inlineDateDecoration; - private ViewSwitcher topLoadMoreView; - private ViewSwitcher bottomLoadMoreView; - private ConversationTypingView typingView; - private View composeDivider; - private ConversationScrollToView scrollToBottomButton; - private ConversationScrollToView scrollToMentionButton; - private TextView scrollDateHeader; - private ConversationBannerView conversationBanner; - private ConversationBannerView emptyConversationBanner; - private MessageRequestViewModel messageRequestViewModel; - private MessageCountsViewModel messageCountsViewModel; - private ConversationViewModel conversationViewModel; - private SnapToTopDataObserver snapToTopDataObserver; - private MarkReadHelper markReadHelper; - private Animation scrollButtonInAnimation; - private Animation mentionButtonInAnimation; - private Animation scrollButtonOutAnimation; - private Animation mentionButtonOutAnimation; - private OnScrollListener conversationScrollListener; - private int pulsePosition = -1; - private VoiceNoteMediaController voiceNoteMediaController; - private View toolbarShadow; - private Stopwatch startupStopwatch; + private LiveRecipient recipient; + private long threadId; + private boolean isReacting; + private ActionMode actionMode; + private Locale locale; + private FrameLayout videoContainer; + private RecyclerView list; + private RecyclerView.ItemDecoration lastSeenDecoration; + private RecyclerView.ItemDecoration inlineDateDecoration; + private ViewSwitcher topLoadMoreView; + private ViewSwitcher bottomLoadMoreView; + private ConversationTypingView typingView; + private View composeDivider; + private ConversationScrollToView scrollToBottomButton; + private ConversationScrollToView scrollToMentionButton; + private TextView scrollDateHeader; + private ConversationBannerView conversationBanner; + private MessageRequestViewModel messageRequestViewModel; + private MessageCountsViewModel messageCountsViewModel; + private ConversationViewModel conversationViewModel; + private SnapToTopDataObserver snapToTopDataObserver; + private MarkReadHelper markReadHelper; + private Animation scrollButtonInAnimation; + private Animation mentionButtonInAnimation; + private Animation scrollButtonOutAnimation; + private Animation mentionButtonOutAnimation; + private OnScrollListener conversationScrollListener; + private int pulsePosition = -1; + private VoiceNoteMediaController voiceNoteMediaController; + private View toolbarShadow; + private Stopwatch startupStopwatch; private GiphyMp4ProjectionRecycler giphyMp4ProjectionRecycler; @@ -240,15 +239,14 @@ public class ConversationFragment extends LoggingFragment { @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle bundle) { final View view = inflater.inflate(R.layout.conversation_fragment, container, false); - videoContainer = view.findViewById(R.id.video_container); - list = view.findViewById(android.R.id.list); - composeDivider = view.findViewById(R.id.compose_divider); + videoContainer = view.findViewById(R.id.video_container); + list = view.findViewById(android.R.id.list); + composeDivider = view.findViewById(R.id.compose_divider); - scrollToBottomButton = view.findViewById(R.id.scroll_to_bottom); - scrollToMentionButton = view.findViewById(R.id.scroll_to_mention); - scrollDateHeader = view.findViewById(R.id.scroll_date_header); - emptyConversationBanner = view.findViewById(R.id.empty_conversation_banner); - toolbarShadow = requireActivity().findViewById(R.id.conversation_toolbar_shadow); + scrollToBottomButton = view.findViewById(R.id.scroll_to_bottom); + scrollToMentionButton = view.findViewById(R.id.scroll_to_mention); + scrollDateHeader = view.findViewById(R.id.scroll_date_header); + toolbarShadow = requireActivity().findViewById(R.id.conversation_toolbar_shadow); final LinearLayoutManager layoutManager = new SmoothScrollingLinearLayoutManager(getActivity(), true); list.setHasFixedSize(false); @@ -483,7 +481,6 @@ public class ConversationFragment extends LoggingFragment { messageRequestViewModel.getRecipientInfo().observe(getViewLifecycleOwner(), recipientInfo -> { presentMessageRequestProfileView(requireContext(), recipientInfo, conversationBanner); - presentMessageRequestProfileView(requireContext(), recipientInfo, emptyConversationBanner); }); messageRequestViewModel.getMessageData().observe(getViewLifecycleOwner(), data -> { @@ -606,7 +603,16 @@ public class ConversationFragment extends LoggingFragment { } private void initializeListAdapter() { - if (this.recipient != null && this.threadId != -1) { + if (threadId == -1) { + toolbarShadow.setVisibility(View.GONE); + } + + if (this.recipient != null) { + if (getListAdapter() != null && getListAdapter().isForRecipientId(this.recipient.getId())) { + Log.d(TAG, "List adapter already initialized for " + this.recipient.getId()); + return; + } + Log.d(TAG, "Initializing adapter for " + recipient.getId()); ConversationAdapter adapter = new ConversationAdapter(this, GlideApp.with(this), locale, selectionClickListener, this.recipient.get(), new AttachmentMediaSourceFactory(requireContext())); adapter.setPagingController(conversationViewModel.getPagingController()); @@ -618,8 +624,6 @@ public class ConversationFragment extends LoggingFragment { setLastSeen(conversationViewModel.getLastSeen()); - emptyConversationBanner.setVisibility(View.GONE); - adapter.registerAdapterDataObserver(new RecyclerView.AdapterDataObserver() { @Override public void onItemRangeInserted(int positionStart, int itemCount) { @@ -631,9 +635,6 @@ public class ConversationFragment extends LoggingFragment { }); } }); - } else if (threadId == -1) { - emptyConversationBanner.setVisibility(View.VISIBLE); - toolbarShadow.setVisibility(View.GONE); } } @@ -726,6 +727,7 @@ public class ConversationFragment extends LoggingFragment { menu.findItem(R.id.menu_context_save_attachment).setVisible(menuState.shouldShowSaveAttachmentAction()); menu.findItem(R.id.menu_context_resend).setVisible(menuState.shouldShowResendAction()); menu.findItem(R.id.menu_context_copy).setVisible(menuState.shouldShowCopyAction()); + menu.findItem(R.id.menu_context_delete_message).setVisible(menuState.shouldShowDeleteAction()); } private @Nullable ConversationAdapter getListAdapter() { @@ -756,7 +758,9 @@ public class ConversationFragment extends LoggingFragment { snapToTopDataObserver.requestScrollPosition(0); conversationViewModel.onConversationDataAvailable(recipient.getId(), threadId, -1); messageCountsViewModel.setThreadId(threadId); + markReadHelper = new MarkReadHelper(threadId, requireContext()); initializeListAdapter(); + initializeTypingObserver(); } } @@ -1229,7 +1233,6 @@ public class ConversationFragment extends LoggingFragment { toolbar.getGlobalVisibleRect(rect); ViewUtil.setTopMargin(scrollDateHeader, rect.bottom + ViewUtil.dpToPx(8)); ViewUtil.setTopMargin(conversationBanner, rect.bottom + ViewUtil.dpToPx(16)); - ViewUtil.setTopMargin(emptyConversationBanner, rect.bottom + ViewUtil.dpToPx(16)); toolbar.getViewTreeObserver().removeOnGlobalLayoutListener(this); } }); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationRepository.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationRepository.java index 14655907e..672ed0eb6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationRepository.java @@ -13,6 +13,7 @@ import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.GroupDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientUtil; import org.thoughtcrime.securesms.util.BubbleUtil; @@ -32,11 +33,11 @@ class ConversationRepository { this.executor = SignalExecutors.BOUNDED; } - LiveData getConversationData(long threadId, int jumpToPosition) { + LiveData getConversationData(long threadId, @NonNull Recipient recipient, int jumpToPosition) { MutableLiveData liveData = new MutableLiveData<>(); executor.execute(() -> { - liveData.postValue(getConversationDataInternal(threadId, jumpToPosition)); + liveData.postValue(getConversationDataInternal(threadId, recipient, jumpToPosition)); }); return liveData; @@ -53,16 +54,17 @@ class ConversationRepository { } } - private @NonNull ConversationData getConversationDataInternal(long threadId, int jumpToPosition) { - ThreadDatabase.ConversationMetadata metadata = DatabaseFactory.getThreadDatabase(context).getConversationMetadata(threadId); - int threadSize = DatabaseFactory.getMmsSmsDatabase(context).getConversationCount(threadId); - long lastSeen = metadata.getLastSeen(); - boolean hasSent = metadata.hasSent(); - int lastSeenPosition = 0; - long lastScrolled = metadata.getLastScrolled(); - int lastScrolledPosition = 0; - boolean isMessageRequestAccepted = RecipientUtil.isMessageRequestAccepted(context, threadId); - ConversationData.MessageRequestData messageRequestData = new ConversationData.MessageRequestData(isMessageRequestAccepted); + private @NonNull ConversationData getConversationDataInternal(long threadId, @NonNull Recipient conversationRecipient, int jumpToPosition) { + ThreadDatabase.ConversationMetadata metadata = DatabaseFactory.getThreadDatabase(context).getConversationMetadata(threadId); + int threadSize = DatabaseFactory.getMmsSmsDatabase(context).getConversationCount(threadId); + long lastSeen = metadata.getLastSeen(); + boolean hasSent = metadata.hasSent(); + int lastSeenPosition = 0; + long lastScrolled = metadata.getLastScrolled(); + int lastScrolledPosition = 0; + boolean isMessageRequestAccepted = RecipientUtil.isMessageRequestAccepted(context, threadId); + ConversationData.MessageRequestData messageRequestData = new ConversationData.MessageRequestData(isMessageRequestAccepted); + boolean showUniversalExpireTimerUpdate = false; if (lastSeen > 0) { lastSeenPosition = DatabaseFactory.getMmsSmsDatabase(context).getMessagePositionOnOrAfterTimestamp(threadId, lastSeen); @@ -79,9 +81,8 @@ class ConversationRepository { if (!isMessageRequestAccepted) { boolean isGroup = false; boolean recipientIsKnownOrHasGroupsInCommon = false; - Recipient threadRecipient = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(threadId); - if (threadRecipient.isGroup()) { - Optional group = DatabaseFactory.getGroupDatabase(context).getGroup(threadRecipient.getId()); + if (conversationRecipient.isGroup()) { + Optional group = DatabaseFactory.getGroupDatabase(context).getGroup(conversationRecipient.getId()); if (group.isPresent()) { List recipients = Recipient.resolvedList(group.get().getMembers()); for (Recipient recipient : recipients) { @@ -92,12 +93,20 @@ class ConversationRepository { } } isGroup = true; - } else if (threadRecipient.hasGroupsInCommon()) { + } else if (conversationRecipient.hasGroupsInCommon()) { recipientIsKnownOrHasGroupsInCommon = true; } messageRequestData = new ConversationData.MessageRequestData(isMessageRequestAccepted, recipientIsKnownOrHasGroupsInCommon, isGroup); } - return new ConversationData(threadId, lastSeen, lastSeenPosition, lastScrolledPosition, hasSent, jumpToPosition, threadSize, messageRequestData); + if (SignalStore.settings().getUniversalExpireTimer() != 0 && + conversationRecipient.getExpireMessages() == 0 && + !conversationRecipient.isGroup() && + (threadId == -1 || !DatabaseFactory.getMmsSmsDatabase(context).hasMeaningfulMessage(threadId))) + { + showUniversalExpireTimerUpdate = true; + } + + return new ConversationData(threadId, lastSeen, lastSeenPosition, lastScrolledPosition, hasSent, jumpToPosition, threadSize, messageRequestData, showUniversalExpireTimerUpdate); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationViewModel.java index 950ddfc72..cf66fc06a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationViewModel.java @@ -69,8 +69,11 @@ public class ConversationViewModel extends ViewModel { this.pagingController = new ProxyPagingController(); this.messageObserver = pagingController::onDataInvalidated; - LiveData metadata = Transformations.switchMap(threadId, thread -> { - LiveData conversationData = conversationRepository.getConversationData(thread, jumpToPosition); + LiveData recipientLiveData = LiveDataUtil.mapAsync(recipientId, Recipient::resolved); + LiveData threadAndRecipient = LiveDataUtil.combineLatest(threadId, recipientLiveData, ThreadAndRecipient::new); + + LiveData metadata = Transformations.switchMap(threadAndRecipient, d -> { + LiveData conversationData = conversationRepository.getConversationData(d.threadId, d.recipient, jumpToPosition); jumpToPosition = -1; @@ -94,12 +97,11 @@ public class ConversationViewModel extends ViewModel { ApplicationDependencies.getDatabaseObserver().unregisterObserver(messageObserver); ApplicationDependencies.getDatabaseObserver().registerConversationObserver(data.getThreadId(), messageObserver); - ConversationDataSource dataSource = new ConversationDataSource(context, data.getThreadId(), messageRequestData); - PagingConfig config = new PagingConfig.Builder() - .setPageSize(25) - .setBufferPages(3) - .setStartIndex(Math.max(startPosition, 0)) - .build(); + ConversationDataSource dataSource = new ConversationDataSource(context, data.getThreadId(), messageRequestData, data.showUniversalExpireTimerMessage()); + PagingConfig config = new PagingConfig.Builder().setPageSize(25) + .setBufferPages(3) + .setStartIndex(Math.max(startPosition, 0)) + .build(); Log.d(TAG, "Starting at position: " + startPosition + " || jumpToPosition: " + data.getJumpToPosition() + ", lastSeenPosition: " + data.getLastSeenPosition() + ", lastScrolledPosition: " + data.getLastScrolledPosition()); return new Pair<>(data.getThreadId(), PagedData.create(dataSource, config)); @@ -213,9 +215,20 @@ public class ConversationViewModel extends ViewModel { SHOW_RECAPTCHA } + private static class ThreadAndRecipient { + + private final long threadId; + private final Recipient recipient; + + public ThreadAndRecipient(long threadId, Recipient recipient) { + this.threadId = threadId; + this.recipient = recipient; + } + } + static class Factory extends ViewModelProvider.NewInstanceFactory { @Override - public @NonNull T create(@NonNull Class modelClass) { + public @NonNull T create(@NonNull Class modelClass) { //noinspection ConstantConditions return modelClass.cast(new ConversationViewModel()); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java index e7fe69b1d..7bd9eaed8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java @@ -17,6 +17,7 @@ final class MenuState { private final boolean saveAttachment; private final boolean resend; private final boolean copy; + private final boolean delete; private MenuState(@NonNull Builder builder) { forward = builder.forward; @@ -25,6 +26,7 @@ final class MenuState { saveAttachment = builder.saveAttachment; resend = builder.resend; copy = builder.copy; + delete = builder.delete; } boolean shouldShowForwardAction() { @@ -51,6 +53,10 @@ final class MenuState { return copy; } + boolean shouldShowDeleteAction() { + return delete; + } + static MenuState getMenuState(@NonNull Recipient conversationRecipient, @NonNull Set messageRecords, boolean shouldShowMessageRequest) @@ -62,11 +68,14 @@ final class MenuState { boolean sharedContact = false; boolean viewOnce = false; boolean remoteDelete = false; + boolean hasInMemory = false; for (MessageRecord messageRecord : messageRecords) { - if (isActionMessage(messageRecord)) - { + if (isActionMessage(messageRecord)) { actionMessage = true; + if (messageRecord.isInMemoryMessageRecord()) { + hasInMemory = true; + } } if (messageRecord.getBody().length() > 0) { @@ -109,6 +118,7 @@ final class MenuState { } return builder.shouldShowCopyAction(!actionMessage && !remoteDelete && hasText) + .shouldShowDeleteAction(!hasInMemory) .build(); } @@ -134,7 +144,8 @@ final class MenuState { messageRecord.isIdentityDefault() || messageRecord.isProfileChange() || messageRecord.isGroupV1MigrationEvent() || - messageRecord.isFailedDecryptionType(); + messageRecord.isFailedDecryptionType() || + messageRecord.isInMemoryMessageRecord(); } private final static class Builder { @@ -145,6 +156,7 @@ final class MenuState { private boolean saveAttachment; private boolean resend; private boolean copy; + private boolean delete; @NonNull Builder shouldShowForwardAction(boolean forward) { this.forward = forward; @@ -176,6 +188,11 @@ final class MenuState { return this; } + @NonNull Builder shouldShowDeleteAction(boolean delete) { + this.delete = delete; + return this; + } + @NonNull MenuState build() { return new MenuState(this); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java index 6347f43b1..411ba2438 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java @@ -77,6 +77,7 @@ public abstract class MessageDatabase extends Database implements MmsSmsColumns public abstract int getMessageCountForThread(long threadId); public abstract int getMessageCountForThread(long threadId, long beforeTime); abstract int getMessageCountForThreadSummary(long threadId); + public abstract boolean hasMeaningfulMessage(long threadId); public abstract Optional getNotification(long messageId); public abstract Cursor getExpirationStartedMessages(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java index 210a9513c..cb77773cd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java @@ -620,6 +620,19 @@ public class MmsDatabase extends MessageDatabase { return 0; } + @Override + public boolean hasMeaningfulMessage(long threadId) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + + String[] cols = new String[] { "1" }; + String query = THREAD_ID + " = ?"; + String[] args = SqlUtil.buildArgs(threadId); + + try (Cursor cursor = db.query(TABLE_NAME, cols, query, args, null, null, null, "1")) { + return cursor != null && cursor.moveToFirst(); + } + } + @Override public void addFailures(long messageId, List failure) { try { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java index 55f0308dc..1ae489e88 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java @@ -337,6 +337,15 @@ public class MmsSmsDatabase extends Database { return count; } + public boolean hasMeaningfulMessage(long threadId) { + if (threadId == -1) { + return false; + } + + return DatabaseFactory.getSmsDatabase(context).hasMeaningfulMessage(threadId) || + DatabaseFactory.getMmsDatabase(context).hasMeaningfulMessage(threadId); + } + public long getThreadForMessageId(long messageId) { long id = DatabaseFactory.getSmsDatabase(context).getThreadIdForMessage(messageId); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java index 030986519..db027d1d4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -233,14 +233,11 @@ public class SmsDatabase extends MessageDatabase { @Override public int getMessageCountForThreadSummary(long threadId) { - SQLiteDatabase db = databaseHelper.getReadableDatabase(); + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + SqlUtil.Query query = buildMeaningfulMessagesQuery(threadId); + String[] cols = { "COUNT(*)" }; - String[] cols = { "COUNT(*)" }; - String query = THREAD_ID + " = ? AND (NOT " + TYPE + " & ? AND TYPE != ?)"; - long type = Types.END_SESSION_BIT | Types.KEY_EXCHANGE_IDENTITY_UPDATE_BIT | Types.KEY_EXCHANGE_IDENTITY_VERIFIED_BIT; - String[] args = SqlUtil.buildArgs(threadId, type, Types.PROFILE_CHANGE_TYPE); - - try (Cursor cursor = db.query(TABLE_NAME, cols, query, args, null, null, null)) { + try (Cursor cursor = db.query(TABLE_NAME, cols, query.getWhere(), query.getWhereArgs(), null, null, null)) { if (cursor != null && cursor.moveToFirst()) { int count = cursor.getInt(0); if (count > 0) { @@ -286,6 +283,22 @@ public class SmsDatabase extends MessageDatabase { return 0; } + @Override + public boolean hasMeaningfulMessage(long threadId) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + SqlUtil.Query query = buildMeaningfulMessagesQuery(threadId); + + try (Cursor cursor = db.query(TABLE_NAME, new String[] { "1" }, query.getWhere(), query.getWhereArgs(), null, null, null, "1")) { + return cursor != null && cursor.moveToFirst(); + } + } + + private @NonNull SqlUtil.Query buildMeaningfulMessagesQuery(long threadId) { + String query = THREAD_ID + " = ? AND (NOT " + TYPE + " & ? AND TYPE != ?)"; + long type = Types.END_SESSION_BIT | Types.KEY_EXCHANGE_IDENTITY_UPDATE_BIT | Types.KEY_EXCHANGE_IDENTITY_VERIFIED_BIT; + return SqlUtil.buildQuery(query, threadId, type, Types.PROFILE_CHANGE_TYPE); + } + @Override public void markAsEndSession(long id) { updateTypeBitmask(id, Types.KEY_EXCHANGE_MASK, Types.END_SESSION_BIT); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java index a104b7eeb..1085a5f1b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -176,8 +176,12 @@ public class ThreadDatabase extends Database { contentValues.put(MESSAGE_COUNT, 0); - SQLiteDatabase db = databaseHelper.getWritableDatabase(); - return db.insert(TABLE_NAME, null, contentValues); + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + long result = db.insert(TABLE_NAME, null, contentValues); + + Recipient.live(recipientId).refresh(); + + return result; } private void updateThread(long threadId, long count, String body, @Nullable Uri attachment, diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducer.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducer.java index 9abfa601c..4952b1e1c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducer.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducer.java @@ -475,7 +475,7 @@ final class GroupsV2UpdateMessageProducer { } } - private void describeNewTimer(@NonNull DecryptedGroupChange change, @NonNull List updates) { + void describeNewTimer(@NonNull DecryptedGroupChange change, @NonNull List updates) { boolean editorIsYou = change.getEditor().equals(selfUuidBytes); if (change.hasNewTimer()) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/InMemoryMessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/InMemoryMessageRecord.java index a3f993bf3..14db5ab3b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/InMemoryMessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/InMemoryMessageRecord.java @@ -7,7 +7,9 @@ import androidx.annotation.Nullable; import androidx.annotation.StringRes; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.ExpirationUtil; import java.util.Collections; @@ -16,6 +18,9 @@ import java.util.Collections; */ public class InMemoryMessageRecord extends MessageRecord { + private static final int NO_GROUPS_IN_COMMON_ID = -1; + private static final int UNIVERSAL_EXPIRE_TIMER_ID = -2; + private InMemoryMessageRecord(long id, String body, Recipient conversationRecipient, @@ -78,7 +83,7 @@ public class InMemoryMessageRecord extends MessageRecord { private final boolean isGroup; public NoGroupsInCommon(long threadId, boolean isGroup) { - super(-1, "", Recipient.UNKNOWN, threadId, 0); + super(NO_GROUPS_IN_COMMON_ID, "", Recipient.UNKNOWN, threadId, 0); this.isGroup = isGroup; } @@ -108,4 +113,28 @@ public class InMemoryMessageRecord extends MessageRecord { return R.string.ConversationUpdateItem_learn_more; } } + + /** + * Show temporary update message about setting the disappearing messages timer upon first message + * send. + */ + public static final class UniversalExpireTimerUpdate extends InMemoryMessageRecord { + + public UniversalExpireTimerUpdate(long threadId) { + super(UNIVERSAL_EXPIRE_TIMER_ID, "", Recipient.UNKNOWN, threadId, 0); + } + + @Override + public @Nullable UpdateDescription getUpdateDisplayBody(@NonNull Context context) { + String update = context.getString(R.string.ConversationUpdateItem_the_disappearing_message_time_will_be_set_to_s_when_you_message_them, + ExpirationUtil.getExpirationDisplayValue(context, SignalStore.settings().getUniversalExpireTimer())); + + return UpdateDescription.staticDescription(update, R.drawable.ic_update_timer_16); + } + + @Override + public boolean isUpdate() { + return true; + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java index 26b9c31fc..0aaf43400 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java @@ -240,11 +240,18 @@ public abstract class MessageRecord extends DisplayRecord { if (decryptedGroupV2Context.hasChange() && (decryptedGroupV2Context.getGroupState().getRevision() != 0 || decryptedGroupV2Context.hasPreviousGroupState())) { return UpdateDescription.concatWithNewLines(updateMessageProducer.describeChanges(decryptedGroupV2Context.getPreviousGroupState(), decryptedGroupV2Context.getChange())); - } else if (selfCreatedGroup(decryptedGroupV2Context.getChange())) { - return UpdateDescription.concatWithNewLines(Arrays.asList(updateMessageProducer.describeNewGroup(decryptedGroupV2Context.getGroupState(), decryptedGroupV2Context.getChange()), - staticUpdateDescription(context.getString(R.string.MessageRecord_invite_friends_to_this_group), 0))); } else { - return updateMessageProducer.describeNewGroup(decryptedGroupV2Context.getGroupState(), decryptedGroupV2Context.getChange()); + List newGroupDescriptions = new ArrayList<>(); + newGroupDescriptions.add(updateMessageProducer.describeNewGroup(decryptedGroupV2Context.getGroupState(), decryptedGroupV2Context.getChange())); + + if (decryptedGroupV2Context.getChange().hasNewTimer()) { + updateMessageProducer.describeNewTimer(decryptedGroupV2Context.getChange(), newGroupDescriptions); + } + + if (selfCreatedGroup(decryptedGroupV2Context.getChange())) { + newGroupDescriptions.add(staticUpdateDescription(context.getString(R.string.MessageRecord_invite_friends_to_this_group), 0)); + } + return UpdateDescription.concatWithNewLines(newGroupDescriptions); } } catch (IOException e) { Log.w(TAG, "GV2 Message update detail could not be read", e); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java index 6c5ea4c07..7a2cf6eb2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java @@ -36,11 +36,12 @@ public final class GroupManager { private static final String TAG = Log.tag(GroupManager.class); @WorkerThread - public static @NonNull GroupActionResult createGroup(@NonNull Context context, - @NonNull Set members, - @Nullable byte[] avatar, - @Nullable String name, - boolean mms) + public static @NonNull GroupActionResult createGroup(@NonNull Context context, + @NonNull Set members, + @Nullable byte[] avatar, + @Nullable String name, + boolean mms, + int disappearingMessagesTimer) throws GroupChangeBusyException, GroupChangeFailedException, IOException { boolean shouldAttemptToCreateV2 = !mms && !SignalStore.internalValues().gv2DoNotCreateGv2Groups(); @@ -49,7 +50,7 @@ public final class GroupManager { if (shouldAttemptToCreateV2) { try { try (GroupManagerV2.GroupCreator groupCreator = new GroupManagerV2(context).create()) { - return groupCreator.createGroup(memberIds, name, avatar); + return groupCreator.createGroup(memberIds, name, avatar, disappearingMessagesTimer); } } catch (MembershipNotSuitableForV2Exception e) { Log.w(TAG, "Attempted to make a GV2, but membership was not suitable, falling back to GV1", e); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java index c4e2fa506..4c39a0377 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java @@ -244,23 +244,15 @@ final class GroupManagerV2 { @WorkerThread @NonNull GroupManager.GroupActionResult createGroup(@NonNull Collection members, @Nullable String name, - @Nullable byte[] avatar) - throws GroupChangeFailedException, IOException, MembershipNotSuitableForV2Exception - { - return createGroup(name, avatar, members); - } - - @WorkerThread - private @NonNull GroupManager.GroupActionResult createGroup(@Nullable String name, - @Nullable byte[] avatar, - @NonNull Collection members) + @Nullable byte[] avatar, + int disappearingMessagesTimer) throws GroupChangeFailedException, IOException, MembershipNotSuitableForV2Exception { GroupSecretParams groupSecretParams = GroupSecretParams.generate(); DecryptedGroup decryptedGroup; try { - decryptedGroup = createGroupOnServer(groupSecretParams, name, avatar, members, Member.Role.DEFAULT, 0); + decryptedGroup = createGroupOnServer(groupSecretParams, name, avatar, members, Member.Role.DEFAULT, disappearingMessagesTimer); } catch (GroupAlreadyExistsException e) { throw new GroupChangeFailedException(e); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupChangeFailureReason.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupChangeFailureReason.java index 1cf6c919b..48245e98f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupChangeFailureReason.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupChangeFailureReason.java @@ -17,7 +17,7 @@ public enum GroupChangeFailureReason { NETWORK, OTHER; - public static @NonNull GroupChangeFailureReason fromException(@NonNull Exception e) { + public static @NonNull GroupChangeFailureReason fromException(@NonNull Throwable e) { if (e instanceof MembershipNotSuitableForV2Exception) return GroupChangeFailureReason.NOT_CAPABLE; if (e instanceof IOException) return GroupChangeFailureReason.NETWORK; if (e instanceof GroupNotAMemberException) return GroupChangeFailureReason.NOT_A_MEMBER; diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsFragment.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsFragment.java index 36761af29..1e0cd5f8f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsFragment.java @@ -12,6 +12,7 @@ import android.view.View; import android.view.ViewGroup; import android.widget.EditText; import android.widget.ImageView; +import android.widget.TextView; import android.widget.Toast; import androidx.annotation.NonNull; @@ -30,8 +31,10 @@ import com.dd.CircularProgressButton; import org.signal.core.util.EditTextUtil; import org.thoughtcrime.securesms.LoggingFragment; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.settings.app.privacy.expire.ExpireTimerSettingsFragment; import org.thoughtcrime.securesms.groups.ui.GroupMemberListView; import org.thoughtcrime.securesms.groups.ui.creategroup.dialogs.NonGv2MemberDialog; +import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.mediasend.AvatarSelectionActivity; import org.thoughtcrime.securesms.mediasend.AvatarSelectionBottomSheetDialogFragment; import org.thoughtcrime.securesms.mediasend.Media; @@ -40,7 +43,9 @@ import org.thoughtcrime.securesms.mms.GlideApp; import org.thoughtcrime.securesms.profiles.AvatarHelper; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.ui.disappearingmessages.RecipientDisappearingMessagesActivity; import org.thoughtcrime.securesms.util.BitmapUtil; +import org.thoughtcrime.securesms.util.ExpirationUtil; import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.ViewUtil; import org.thoughtcrime.securesms.util.text.AfterTextChanged; @@ -54,6 +59,7 @@ public class AddGroupDetailsFragment extends LoggingFragment { private static final int AVATAR_PLACEHOLDER_INSET_DP = 18; private static final short REQUEST_CODE_AVATAR = 27621; + private static final short REQUEST_DISAPPEARING_TIMER = 28621; private CircularProgressButton create; private Callback callback; @@ -61,6 +67,7 @@ public class AddGroupDetailsFragment extends LoggingFragment { private Drawable avatarPlaceholder; private EditText name; private Toolbar toolbar; + private View disappearingMessagesRow; @Override public void onAttach(@NonNull Context context) { @@ -83,17 +90,19 @@ public class AddGroupDetailsFragment extends LoggingFragment { @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - create = view.findViewById(R.id.create); - name = view.findViewById(R.id.name); - toolbar = view.findViewById(R.id.toolbar); + create = view.findViewById(R.id.create); + name = view.findViewById(R.id.name); + toolbar = view.findViewById(R.id.toolbar); + disappearingMessagesRow = view.findViewById(R.id.group_disappearing_messages_row); setCreateEnabled(false, false); - GroupMemberListView members = view.findViewById(R.id.member_list); - ImageView avatar = view.findViewById(R.id.group_avatar); - View mmsWarning = view.findViewById(R.id.mms_warning); - LearnMoreTextView gv2Warning = view.findViewById(R.id.gv2_warning); - View addLater = view.findViewById(R.id.add_later); + GroupMemberListView members = view.findViewById(R.id.member_list); + ImageView avatar = view.findViewById(R.id.group_avatar); + View mmsWarning = view.findViewById(R.id.mms_warning); + LearnMoreTextView gv2Warning = view.findViewById(R.id.gv2_warning); + View addLater = view.findViewById(R.id.add_later); + TextView disappearingMessageValue = view.findViewById(R.id.group_disappearing_messages_value); avatarPlaceholder = VectorDrawableCompat.create(getResources(), R.drawable.ic_camera_outline_32_ultramarine, requireActivity().getTheme()); @@ -115,6 +124,7 @@ public class AddGroupDetailsFragment extends LoggingFragment { }); viewModel.getCanSubmitForm().observe(getViewLifecycleOwner(), isFormValid -> setCreateEnabled(isFormValid, true)); viewModel.getIsMms().observe(getViewLifecycleOwner(), isMms -> { + disappearingMessagesRow.setVisibility(isMms ? View.GONE : View.VISIBLE); mmsWarning.setVisibility(isMms ? View.VISIBLE : View.GONE); name.setHint(isMms ? R.string.AddGroupDetailsFragment__group_name_optional : R.string.AddGroupDetailsFragment__group_name_required); toolbar.setTitle(isMms ? R.string.AddGroupDetailsFragment__create_group : R.string.AddGroupDetailsFragment__name_this_group); @@ -143,6 +153,11 @@ public class AddGroupDetailsFragment extends LoggingFragment { } }); + viewModel.getDisappearingMessagesTimer().observe(getViewLifecycleOwner(), timer -> disappearingMessageValue.setText(ExpirationUtil.getExpirationDisplayValue(requireContext(), timer))); + disappearingMessagesRow.setOnClickListener(v -> { + startActivityForResult(RecipientDisappearingMessagesActivity.forCreateGroup(requireContext(), viewModel.getDisappearingMessagesTimer().getValue()), REQUEST_DISAPPEARING_TIMER); + }); + name.requestFocus(); } @@ -175,6 +190,8 @@ public class AddGroupDetailsFragment extends LoggingFragment { public void onLoadCleared(@Nullable Drawable placeholder) { } }); + } else if (requestCode == REQUEST_DISAPPEARING_TIMER && resultCode == Activity.RESULT_OK && data != null) { + viewModel.setDisappearingMessageTimer(data.getIntExtra(ExpireTimerSettingsFragment.FOR_RESULT_VALUE, SignalStore.settings().getUniversalExpireTimer())); } else { super.onActivityResult(requestCode, resultCode, data); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsRepository.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsRepository.java index e36d89771..62b322a1a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsRepository.java @@ -16,6 +16,7 @@ import org.thoughtcrime.securesms.groups.GroupChangeException; import org.thoughtcrime.securesms.groups.GroupManager; import org.thoughtcrime.securesms.groups.GroupsV2CapabilityChecker; import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry; +import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; @@ -48,17 +49,24 @@ final class AddGroupDetailsRepository { }); } - void createGroup(@NonNull Set members, - @Nullable byte[] avatar, - @Nullable String name, - boolean mms, + void createGroup(@NonNull Set members, + @Nullable byte[] avatar, + @Nullable String name, + boolean mms, + @Nullable Integer disappearingMessagesTimer, Consumer resultConsumer) { SignalExecutors.BOUNDED.execute(() -> { Set recipients = new HashSet<>(Stream.of(members).map(Recipient::resolved).toList()); try { - GroupManager.GroupActionResult result = GroupManager.createGroup(context, recipients, avatar, name, mms); + GroupManager.GroupActionResult result = GroupManager.createGroup(context, + recipients, + avatar, + name, + mms, + disappearingMessagesTimer != null ? disappearingMessagesTimer + : SignalStore.settings().getUniversalExpireTimer()); resultConsumer.accept(GroupCreateResult.success(result)); } catch (GroupChangeBusyException e) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsViewModel.java index d225781d3..2b1a792e1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsViewModel.java @@ -32,10 +32,11 @@ import java.util.Set; public final class AddGroupDetailsViewModel extends ViewModel { private final LiveData> members; - private final DefaultValueLiveData> deleted = new DefaultValueLiveData<>(new HashSet<>()); - private final MutableLiveData name = new MutableLiveData<>(""); - private final MutableLiveData avatar = new MutableLiveData<>(); - private final SingleLiveEvent groupCreateResult = new SingleLiveEvent<>(); + private final DefaultValueLiveData> deleted = new DefaultValueLiveData<>(new HashSet<>()); + private final MutableLiveData name = new MutableLiveData<>(""); + private final MutableLiveData avatar = new MutableLiveData<>(); + private final SingleLiveEvent groupCreateResult = new SingleLiveEvent<>(); + private final MutableLiveData disappearingMessagesTimer = new MutableLiveData<>(SignalStore.settings().getUniversalExpireTimer()); private final LiveData isMms; private final LiveData canSubmitForm; private final AddGroupDetailsRepository repository; @@ -47,12 +48,10 @@ public final class AddGroupDetailsViewModel extends ViewModel { this.repository = repository; MutableLiveData> initialMembers = new MutableLiveData<>(); + LiveData isValidName = Transformations.map(name, name -> !TextUtils.isEmpty(name)); - LiveData isValidName = Transformations.map(name, name -> !TextUtils.isEmpty(name)); - - members = LiveDataUtil.combineLatest(initialMembers, deleted, AddGroupDetailsViewModel::filterDeletedMembers); - - isMms = Transformations.map(members, AddGroupDetailsViewModel::isAnyForcedSms); + members = LiveDataUtil.combineLatest(initialMembers, deleted, AddGroupDetailsViewModel::filterDeletedMembers); + isMms = Transformations.map(members, AddGroupDetailsViewModel::isAnyForcedSms); LiveData> membersToCheckGv2CapabilityOf = LiveDataUtil.combineLatest(isMms, members, (forcedMms, memberList) -> { if (SignalStore.internalValues().gv2DoNotCreateGv2Groups() || forcedMms) { @@ -94,6 +93,10 @@ public final class AddGroupDetailsViewModel extends ViewModel { return nonGv2CapableMembers; } + @NonNull LiveData getDisappearingMessagesTimer() { + return disappearingMessagesTimer; + } + void setAvatar(@Nullable byte[] avatar) { this.avatar.setValue(avatar); } @@ -107,18 +110,19 @@ public final class AddGroupDetailsViewModel extends ViewModel { } void delete(@NonNull RecipientId recipientId) { - Set deleted = this.deleted.getValue(); + Set deleted = this.deleted.getValue(); deleted.add(recipientId); this.deleted.setValue(deleted); } void create() { - List members = Objects.requireNonNull(this.members.getValue()); - Set memberIds = Stream.of(members).map(member -> member.getMember().getId()).collect(Collectors.toSet()); - byte[] avatarBytes = avatar.getValue(); - boolean isGroupMms = isMms.getValue() == Boolean.TRUE; - String groupName = name.getValue(); + List members = Objects.requireNonNull(this.members.getValue()); + Set memberIds = Stream.of(members).map(member -> member.getMember().getId()).collect(Collectors.toSet()); + byte[] avatarBytes = avatar.getValue(); + boolean isGroupMms = isMms.getValue() == Boolean.TRUE; + String groupName = name.getValue(); + Integer disappearingTimer = disappearingMessagesTimer.getValue(); if (!isGroupMms && TextUtils.isEmpty(groupName)) { groupCreateResult.postValue(GroupCreateResult.error(GroupCreateResult.Error.Type.ERROR_INVALID_NAME)); @@ -129,6 +133,7 @@ public final class AddGroupDetailsViewModel extends ViewModel { avatarBytes, groupName, isGroupMms, + disappearingTimer, groupCreateResult::postValue); } @@ -143,6 +148,10 @@ public final class AddGroupDetailsViewModel extends ViewModel { .anyMatch(member -> !member.getMember().isRegistered()); } + public void setDisappearingMessageTimer(int timer) { + disappearingMessagesTimer.setValue(timer); + } + static final class Factory implements ViewModelProvider.Factory { private final Collection recipientIds; diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupFragment.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupFragment.java index 238908fad..0c4cc6480 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupFragment.java @@ -58,6 +58,7 @@ import org.thoughtcrime.securesms.profiles.edit.EditProfileActivity; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment; +import org.thoughtcrime.securesms.recipients.ui.disappearingmessages.RecipientDisappearingMessagesActivity; import org.thoughtcrime.securesms.recipients.ui.notifications.CustomNotificationsDialogFragment; import org.thoughtcrime.securesms.recipients.ui.sharablegrouplink.ShareableGroupLinkDialogFragment; import org.thoughtcrime.securesms.util.AsynchronousCallback; @@ -280,7 +281,12 @@ public class ManageGroupFragment extends LoggingFragment { viewModel.getDisappearingMessageTimer().observe(getViewLifecycleOwner(), string -> disappearingMessages.setText(string)); - disappearingMessagesRow.setOnClickListener(v -> viewModel.handleExpirationSelection()); + disappearingMessagesRow.setOnClickListener(v -> { + Recipient recipient = viewModel.getGroupRecipient().getValue(); + if (recipient != null) { + startActivity(RecipientDisappearingMessagesActivity.forRecipient(requireContext(), recipient.getId())); + } + }); blockGroup.setOnClickListener(v -> viewModel.blockAndLeave(requireActivity())); unblockGroup.setOnClickListener(v -> viewModel.unblock(requireActivity())); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupRepository.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupRepository.java index d0fd5e03a..9212497a8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupRepository.java @@ -79,17 +79,6 @@ final class ManageGroupRepository { return new GroupStateResult(threadId, groupRecipient); } - void setExpiration(@NonNull GroupId groupId, int newExpirationTime, @NonNull GroupChangeErrorCallback error) { - SignalExecutors.UNBOUNDED.execute(() -> { - try { - GroupManager.updateGroupTimer(context, groupId.requirePush(), newExpirationTime); - } catch (GroupChangeException | IOException e) { - Log.w(TAG, e); - error.onError(GroupChangeFailureReason.fromException(e)); - } - }); - } - void applyMembershipRightsChange(@NonNull GroupId groupId, @NonNull GroupAccessControl newRights, @NonNull GroupChangeErrorCallback error) { SignalExecutors.UNBOUNDED.execute(() -> { try { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupViewModel.java index d5182bb02..7926e17f2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupViewModel.java @@ -257,14 +257,6 @@ public class ManageGroupViewModel extends ViewModel { return groupInfoMessage; } - void handleExpirationSelection() { - manageGroupRepository.getRecipient(getGroupId(), - groupRecipient -> - ExpirationDialog.show(context, - groupRecipient.getExpireMessages(), - expirationTime -> manageGroupRepository.setExpiration(getGroupId(), expirationTime, this::showErrorToast))); - } - void applyMembershipRightsChange(@NonNull GroupAccessControl newRights) { manageGroupRepository.applyMembershipRightsChange(getGroupId(), newRights, this::showErrorToast); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index b5607cc1c..37f5f4016 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -30,6 +30,7 @@ import org.thoughtcrime.securesms.jobmanager.migrations.RecipientIdJobMigration; import org.thoughtcrime.securesms.jobmanager.migrations.RetrieveProfileJobMigration; import org.thoughtcrime.securesms.jobmanager.migrations.SendReadReceiptsJobMigration; import org.thoughtcrime.securesms.migrations.AccountRecordMigrationJob; +import org.thoughtcrime.securesms.migrations.ApplyUnknownFieldsToSelfMigrationJob; import org.thoughtcrime.securesms.migrations.AttributesMigrationJob; import org.thoughtcrime.securesms.migrations.AvatarIdRemovalMigrationJob; import org.thoughtcrime.securesms.migrations.AvatarMigrationJob; @@ -160,6 +161,7 @@ public final class JobManagerFactories { // Migrations put(AccountRecordMigrationJob.KEY, new AccountRecordMigrationJob.Factory()); + put(ApplyUnknownFieldsToSelfMigrationJob.KEY, new ApplyUnknownFieldsToSelfMigrationJob.Factory()); put(AttributesMigrationJob.KEY, new AttributesMigrationJob.Factory()); put(AvatarIdRemovalMigrationJob.KEY, new AvatarIdRemovalMigrationJob.Factory()); put(AvatarMigrationJob.KEY, new AvatarMigrationJob.Factory()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SettingsValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SettingsValues.java index d1fbb22b3..615a583b1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SettingsValues.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SettingsValues.java @@ -9,15 +9,15 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.lifecycle.LiveData; +import org.signal.core.util.concurrent.SignalExecutors; +import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.preferences.widgets.NotificationPrivacyPreference; -import org.thoughtcrime.securesms.util.SingleLiveEvent; -import org.thoughtcrime.securesms.util.TextSecurePreferences; -import org.signal.core.util.concurrent.SignalExecutors; -import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.storage.StorageSyncHelper; +import org.thoughtcrime.securesms.util.SingleLiveEvent; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.webrtc.CallBandwidthMode; import java.util.Arrays; @@ -41,31 +41,31 @@ public final class SettingsValues extends SignalStoreValues { public static final String THREAD_TRIM_LENGTH = "pref_trim_length"; public static final String THREAD_TRIM_ENABLED = "pref_trim_threads"; - public static final String THEME = "settings.theme"; - public static final String MESSAGE_FONT_SIZE = "settings.message.font.size"; - public static final String LANGUAGE = "settings.language"; - public static final String PREFER_SYSTEM_EMOJI = "settings.use.system.emoji"; - public static final String ENTER_KEY_SENDS = "settings.enter.key.sends"; - public static final String BACKUPS_ENABLED = "settings.backups.enabled"; - public static final String SMS_DELIVERY_REPORTS_ENABLED = "settings.sms.delivery.reports.enabled"; - public static final String WIFI_CALLING_COMPATIBILITY_MODE_ENABLED = "settings.wifi.calling.compatibility.mode.enabled"; - public static final String MESSAGE_NOTIFICATIONS_ENABLED = "settings.message.notifications.enabled"; - public static final String MESSAGE_NOTIFICATION_SOUND = "settings.message.notifications.sound"; - public static final String MESSAGE_VIBRATE_ENABLED = "settings.message.vibrate.enabled"; - public static final String MESSAGE_LED_COLOR = "settings.message.led.color"; - public static final String MESSAGE_LED_BLINK_PATTERN = "settings.message.led.blink"; - public static final String MESSAGE_IN_CHAT_SOUNDS_ENABLED = "settings.message.in.chats.sounds.enabled"; - public static final String MESSAGE_REPEAT_ALERTS = "settings.message.repeat.alerts"; - public static final String MESSAGE_NOTIFICATION_PRIVACY = "settings.message.notification.privacy"; - public static final String CALL_NOTIFICATIONS_ENABLED = "settings.call.notifications.enabled"; - public static final String CALL_RINGTONE = "settings.call.ringtone"; - public static final String CALL_VIBRATE_ENABLED = "settings.call.vibrate.enabled"; - public static final String NOTIFY_WHEN_CONTACT_JOINS_SIGNAL = "settings.notify.when.contact.joins.signal"; + public static final String THEME = "settings.theme"; + public static final String MESSAGE_FONT_SIZE = "settings.message.font.size"; + public static final String LANGUAGE = "settings.language"; + public static final String PREFER_SYSTEM_EMOJI = "settings.use.system.emoji"; + public static final String ENTER_KEY_SENDS = "settings.enter.key.sends"; + public static final String BACKUPS_ENABLED = "settings.backups.enabled"; + public static final String SMS_DELIVERY_REPORTS_ENABLED = "settings.sms.delivery.reports.enabled"; + public static final String WIFI_CALLING_COMPATIBILITY_MODE_ENABLED = "settings.wifi.calling.compatibility.mode.enabled"; + public static final String MESSAGE_NOTIFICATIONS_ENABLED = "settings.message.notifications.enabled"; + public static final String MESSAGE_NOTIFICATION_SOUND = "settings.message.notifications.sound"; + public static final String MESSAGE_VIBRATE_ENABLED = "settings.message.vibrate.enabled"; + public static final String MESSAGE_LED_COLOR = "settings.message.led.color"; + public static final String MESSAGE_LED_BLINK_PATTERN = "settings.message.led.blink"; + public static final String MESSAGE_IN_CHAT_SOUNDS_ENABLED = "settings.message.in.chats.sounds.enabled"; + public static final String MESSAGE_REPEAT_ALERTS = "settings.message.repeat.alerts"; + public static final String MESSAGE_NOTIFICATION_PRIVACY = "settings.message.notification.privacy"; + public static final String CALL_NOTIFICATIONS_ENABLED = "settings.call.notifications.enabled"; + public static final String CALL_RINGTONE = "settings.call.ringtone"; + public static final String CALL_VIBRATE_ENABLED = "settings.call.vibrate.enabled"; + public static final String NOTIFY_WHEN_CONTACT_JOINS_SIGNAL = "settings.notify.when.contact.joins.signal"; + private static final String DEFAULT_SMS = "settings.default_sms"; + private static final String UNIVERSAL_EXPIRE_TIMER = "settings.universal.expire.timer"; private final SingleLiveEvent onConfigurationSettingChanged = new SingleLiveEvent<>(); - private static final String DEFAULT_SMS = "settings.default_sms"; - SettingsValues(@NonNull KeyValueStore store) { super(store); } @@ -104,7 +104,8 @@ public final class SettingsValues extends SignalStoreValues { CALL_NOTIFICATIONS_ENABLED, CALL_RINGTONE, CALL_VIBRATE_ENABLED, - NOTIFY_WHEN_CONTACT_JOINS_SIGNAL); + NOTIFY_WHEN_CONTACT_JOINS_SIGNAL, + UNIVERSAL_EXPIRE_TIMER); } public @NonNull LiveData getOnConfigurationSettingChanged() { @@ -370,6 +371,18 @@ public final class SettingsValues extends SignalStoreValues { } } + public void setUniversalExpireTimer(int seconds) { + putInteger(UNIVERSAL_EXPIRE_TIMER, seconds); + SignalExecutors.BOUNDED.execute(() -> { + DatabaseFactory.getRecipientDatabase(ApplicationDependencies.getApplication()).markNeedsSync(Recipient.self().getId()); + StorageSyncHelper.scheduleSyncForDataChange(); + }); + } + + public int getUniversalExpireTimer() { + return getInteger(UNIVERSAL_EXPIRE_TIMER, 0); + } + private @Nullable Uri getUri(@NonNull String key) { String uri = getString(key, ""); diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java index 81458f45a..7c62a4180 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java @@ -40,7 +40,7 @@ public class ApplicationMigrations { private static final int LEGACY_CANONICAL_VERSION = 455; - public static final int CURRENT_VERSION = 33; + public static final int CURRENT_VERSION = 34; private static final class Version { static final int LEGACY = 1; @@ -75,6 +75,7 @@ public class ApplicationMigrations { static final int MUTE_SYNC = 31; static final int PROFILE_SHARING_UPDATE = 32; static final int SMS_STORAGE_SYNC = 33; + static final int APPLY_UNIVERSAL_EXPIRE = 34; } /** @@ -317,6 +318,10 @@ public class ApplicationMigrations { jobs.put(Version.SMS_STORAGE_SYNC, new AccountRecordMigrationJob()); } + if (lastSeenVersion < Version.APPLY_UNIVERSAL_EXPIRE) { + jobs.put(Version.SMS_STORAGE_SYNC, new ApplyUnknownFieldsToSelfMigrationJob()); + } + return jobs; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplyUnknownFieldsToSelfMigrationJob.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplyUnknownFieldsToSelfMigrationJob.java new file mode 100644 index 000000000..96d705bcc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplyUnknownFieldsToSelfMigrationJob.java @@ -0,0 +1,86 @@ +package org.thoughtcrime.securesms.migrations; + +import androidx.annotation.NonNull; + +import com.google.protobuf.InvalidProtocolBufferException; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobs.StorageSyncJob; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.storage.StorageSyncHelper; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.signalservice.api.storage.SignalAccountRecord; +import org.whispersystems.signalservice.api.storage.StorageId; +import org.whispersystems.signalservice.internal.storage.protos.AccountRecord; + +/** + * Check for unknown fields stored on self and attempt to apply them. + */ +public class ApplyUnknownFieldsToSelfMigrationJob extends MigrationJob { + + private static final String TAG = Log.tag(ApplyUnknownFieldsToSelfMigrationJob.class); + + public static final String KEY = "ApplyUnknownFieldsToSelfMigrationJob"; + + ApplyUnknownFieldsToSelfMigrationJob() { + this(new Parameters.Builder().build()); + } + + private ApplyUnknownFieldsToSelfMigrationJob(@NonNull Parameters parameters) { + super(parameters); + } + + @Override + public boolean isUiBlocking() { + return false; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void performMigration() { + if (!TextSecurePreferences.isPushRegistered(context) || TextSecurePreferences.getLocalUuid(context) == null) { + Log.w(TAG, "Not registered!"); + return; + } + + Recipient self = Recipient.self(); + RecipientDatabase.RecipientSettings settings = DatabaseFactory.getRecipientDatabase(context).getRecipientSettingsForSync(self.getId()); + + if (settings == null || settings.getSyncExtras().getStorageProto() == null) { + Log.d(TAG, "No unknowns to apply"); + return; + } + + try { + StorageId storageId = StorageId.forAccount(self.getStorageServiceId()); + AccountRecord accountRecord = AccountRecord.parseFrom(settings.getSyncExtras().getStorageProto()); + SignalAccountRecord signalAccountRecord = new SignalAccountRecord(storageId, accountRecord); + + Log.d(TAG, "Applying potentially now known unknowns"); + StorageSyncHelper.applyAccountStorageSyncUpdates(context, self, signalAccountRecord, false); + } catch (InvalidProtocolBufferException e) { + Log.w(TAG, e); + } + } + + @Override + boolean shouldRetry(@NonNull Exception e) { + return false; + } + + public static class Factory implements Job.Factory { + @Override + public @NonNull ApplyUnknownFieldsToSelfMigrationJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new ApplyUnknownFieldsToSelfMigrationJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMediaMessage.java b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMediaMessage.java index 505fd503a..0a70ff7e4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMediaMessage.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMediaMessage.java @@ -78,6 +78,23 @@ public class OutgoingMediaMessage { contacts, linkPreviews, mentions, new LinkedList<>(), new LinkedList<>()); } + public OutgoingMediaMessage(OutgoingMediaMessage that, long expiresIn) { + this(that.getRecipient(), + that.body, + that.attachments, + that.sentTimeMillis, + that.subscriptionId, + expiresIn, + that.viewOnce, + that.distributionType, + that.outgoingQuote, + that.contacts, + that.linkPreviews, + that.mentions, + that.networkFailures, + that.identityKeyMismatches); + } + public OutgoingMediaMessage(OutgoingMediaMessage that) { this.recipient = that.getRecipient(); this.body = that.body; diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientUtil.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientUtil.java index 5ac8926ca..419d7c5e8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientUtil.java @@ -23,6 +23,8 @@ import org.thoughtcrime.securesms.jobs.MultiDeviceBlockedUpdateJob; import org.thoughtcrime.securesms.jobs.MultiDeviceMessageRequestResponseJob; import org.thoughtcrime.securesms.jobs.RotateProfileKeyJob; import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.mms.OutgoingExpirationUpdateMessage; +import org.thoughtcrime.securesms.sms.MessageSender; import org.thoughtcrime.securesms.storage.StorageSyncHelper; import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.push.SignalServiceAddress; @@ -285,6 +287,26 @@ public class RecipientUtil { } } + /** + * Checks if a universal timer is set and if the thread should have it set on it. Attempts to abort quickly and perform + * minimal database access. + */ + @WorkerThread + public static boolean setAndSendUniversalExpireTimerIfNecessary(@NonNull Context context, @NonNull Recipient recipient, long threadId) { + int defaultTimer = SignalStore.settings().getUniversalExpireTimer(); + if (defaultTimer == 0 || recipient.isGroup() || recipient.getExpireMessages() != 0) { + return false; + } + + if (threadId == -1 || !DatabaseFactory.getMmsSmsDatabase(context).hasMeaningfulMessage(threadId)) { + DatabaseFactory.getRecipientDatabase(context).setExpireMessages(recipient.getId(), defaultTimer); + OutgoingExpirationUpdateMessage outgoingMessage = new OutgoingExpirationUpdateMessage(recipient, System.currentTimeMillis(), defaultTimer * 1000L); + MessageSender.send(context, outgoingMessage, DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipient), false, null); + return true; + } + return false; + } + @WorkerThread private static boolean isMessageRequestAccepted(@NonNull Context context, long threadId, @NonNull Recipient threadRecipient) { return threadRecipient.isSelf() || diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/disappearingmessages/RecipientDisappearingMessagesActivity.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/disappearingmessages/RecipientDisappearingMessagesActivity.java new file mode 100644 index 000000000..90f003bac --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/disappearingmessages/RecipientDisappearingMessagesActivity.java @@ -0,0 +1,38 @@ +package org.thoughtcrime.securesms.recipients.ui.disappearingmessages; + +import android.content.Context; +import android.content.Intent; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.settings.DSLSettingsActivity; +import org.thoughtcrime.securesms.components.settings.app.privacy.expire.ExpireTimerSettingsFragmentArgs; +import org.thoughtcrime.securesms.recipients.RecipientId; + +/** + * For select a expire timer for a recipient (individual or group). + */ +public final class RecipientDisappearingMessagesActivity extends DSLSettingsActivity { + + public static @NonNull Intent forRecipient(@NonNull Context context, @NonNull RecipientId recipientId) { + Intent intent = new Intent(context, RecipientDisappearingMessagesActivity.class); + intent.putExtra(DSLSettingsActivity.ARG_NAV_GRAPH, R.navigation.app_settings_expire_timer) + .putExtra(DSLSettingsActivity.ARG_START_BUNDLE, new ExpireTimerSettingsFragmentArgs.Builder().setRecipientId(recipientId).build().toBundle()); + + return intent; + } + + public static @NonNull Intent forCreateGroup(@NonNull Context context, @Nullable Integer initialValue) { + Intent intent = new Intent(context, RecipientDisappearingMessagesActivity.class); + intent.putExtra(DSLSettingsActivity.ARG_NAV_GRAPH, R.navigation.app_settings_expire_timer) + .putExtra(DSLSettingsActivity.ARG_START_BUNDLE, new ExpireTimerSettingsFragmentArgs.Builder().setForResultMode(true) + .setInitialValue(initialValue) + .build() + .toBundle()); + + return intent; + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientFragment.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientFragment.java index b5b844890..e532ccae4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientFragment.java @@ -49,6 +49,7 @@ import org.thoughtcrime.securesms.profiles.edit.EditProfileActivity; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientExporter; import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.ui.disappearingmessages.RecipientDisappearingMessagesActivity; import org.thoughtcrime.securesms.recipients.ui.notifications.CustomNotificationsDialogFragment; import org.thoughtcrime.securesms.util.DateUtils; import org.thoughtcrime.securesms.util.LifecycleCursorWrapper; @@ -239,7 +240,7 @@ public class ManageRecipientFragment extends LoggingFragment { internalDetails.setVisibility(View.GONE); } - disappearingMessagesRow.setOnClickListener(v -> viewModel.handleExpirationSelection(requireContext())); + disappearingMessagesRow.setOnClickListener(v -> startActivity(RecipientDisappearingMessagesActivity.forRecipient(requireContext(), recipientId))); block.setOnClickListener(v -> viewModel.onBlockClicked(requireActivity())); unblock.setOnClickListener(v -> viewModel.onUnblockClicked(requireActivity())); diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientRepository.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientRepository.java index c031f1382..a80b5e765 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientRepository.java @@ -19,10 +19,8 @@ import org.thoughtcrime.securesms.database.IdentityDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob; -import org.thoughtcrime.securesms.mms.OutgoingExpirationUpdateMessage; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; -import org.thoughtcrime.securesms.sms.MessageSender; import java.io.IOException; import java.util.ArrayList; @@ -62,14 +60,6 @@ final class ManageRecipientRepository { .orNull())); } - void setExpiration(int newExpirationTime) { - SignalExecutors.BOUNDED.execute(() -> { - DatabaseFactory.getRecipientDatabase(context).setExpireMessages(recipientId, newExpirationTime); - OutgoingExpirationUpdateMessage outgoingMessage = new OutgoingExpirationUpdateMessage(Recipient.resolved(recipientId), System.currentTimeMillis(), newExpirationTime * 1000L); - MessageSender.send(context, outgoingMessage, getThreadId(), false, null); - }); - } - void getGroupMembership(@NonNull Consumer> onComplete) { SignalExecutors.BOUNDED.execute(() -> { GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientViewModel.java index 73d105fee..f16e480c8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/managerecipient/ManageRecipientViewModel.java @@ -187,13 +187,6 @@ public final class ManageRecipientViewModel extends ViewModel { return canUnblock; } - void handleExpirationSelection(@NonNull Context context) { - withRecipient(recipient -> - ExpirationDialog.show(context, - recipient.getExpireMessages(), - manageRecipientRepository::setExpiration)); - } - void setMuteUntil(long muteUntil) { manageRecipientRepository.setMuteUntil(muteUntil); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/OutgoingCallActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/OutgoingCallActionProcessor.java index 104da7728..fde99a367 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/OutgoingCallActionProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/OutgoingCallActionProcessor.java @@ -15,6 +15,8 @@ import org.thoughtcrime.securesms.crypto.IdentityKeyUtil; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.events.CallParticipant; import org.thoughtcrime.securesms.events.WebRtcViewModel; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientUtil; import org.thoughtcrime.securesms.ringrtc.RemotePeer; import org.thoughtcrime.securesms.service.webrtc.WebRtcData.CallMetadata; import org.thoughtcrime.securesms.service.webrtc.WebRtcData.OfferMetadata; @@ -76,6 +78,7 @@ public class OutgoingCallActionProcessor extends DeviceAwareActionProcessor { webRtcInteractor.setCallInProgressNotification(TYPE_OUTGOING_RINGING, remotePeer); webRtcInteractor.setWantsBluetoothConnection(true); + RecipientUtil.setAndSendUniversalExpireTimerIfNecessary(context, Recipient.resolved(remotePeer.getId()), DatabaseFactory.getThreadDatabase(context).getThreadIdIfExistsFor(remotePeer.getId())); DatabaseFactory.getSmsDatabase(context).insertOutgoingCall(remotePeer.getId(), currentState.getCallSetupState().isEnableVideoOnCreate()); webRtcInteractor.retrieveTurnServers(remotePeer); @@ -97,6 +100,8 @@ public class OutgoingCallActionProcessor extends DeviceAwareActionProcessor { Integer destinationDeviceId = broadcast ? null : callMetadata.getRemoteDevice(); SignalServiceCallMessage callMessage = SignalServiceCallMessage.forOffer(offerMessage, true, destinationDeviceId); + Recipient callRecipient = currentState.getCallInfoState().getCallRecipient(); + RecipientUtil.shareProfileIfFirstSecureMessage(context, callRecipient); webRtcInteractor.sendCallMessage(callMetadata.getRemotePeer(), callMessage); return currentState; diff --git a/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java b/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java index 2d1c6df90..80d27587e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java @@ -28,7 +28,6 @@ import com.annimon.stream.Stream; import org.greenrobot.eventbus.EventBus; import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.attachments.Attachment; import org.thoughtcrime.securesms.attachments.AttachmentId; import org.thoughtcrime.securesms.attachments.DatabaseAttachment; @@ -62,12 +61,14 @@ import org.thoughtcrime.securesms.jobs.ReactionSendJob; import org.thoughtcrime.securesms.jobs.RemoteDeleteSendJob; import org.thoughtcrime.securesms.jobs.ResumableUploadSpecJob; import org.thoughtcrime.securesms.jobs.SmsSendJob; +import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.linkpreview.LinkPreview; import org.thoughtcrime.securesms.mms.MmsException; import org.thoughtcrime.securesms.mms.OutgoingMediaMessage; import org.thoughtcrime.securesms.mms.OutgoingSecureMediaMessage; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; import org.thoughtcrime.securesms.service.ExpiringMessageManager; import org.thoughtcrime.securesms.util.ParcelUtil; import org.thoughtcrime.securesms.util.TextSecurePreferences; @@ -106,7 +107,11 @@ public class MessageSender { boolean keyExchange = message.isKeyExchange(); long allocatedThreadId = DatabaseFactory.getThreadDatabase(context).getOrCreateValidThreadId(recipient, threadId); - long messageId = database.insertMessageOutbox(allocatedThreadId, message, forceSms, System.currentTimeMillis(), insertListener); + long messageId = database.insertMessageOutbox(allocatedThreadId, + applyUniversalExpireTimerIfNecessary(context, recipient, message, allocatedThreadId), + forceSms, + System.currentTimeMillis(), + insertListener); sendTextMessage(context, recipient, forceSms, keyExchange, messageId); onMessageSent(); @@ -127,7 +132,7 @@ public class MessageSender { long allocatedThreadId = threadDatabase.getOrCreateValidThreadId(message.getRecipient(), threadId, message.getDistributionType()); Recipient recipient = message.getRecipient(); - long messageId = database.insertMessageOutbox(message, allocatedThreadId, forceSms, insertListener); + long messageId = database.insertMessageOutbox(applyUniversalExpireTimerIfNecessary(context, recipient, message, allocatedThreadId), allocatedThreadId, forceSms, insertListener); sendMediaMessage(context, recipient, forceSms, messageId, Collections.emptyList()); onMessageSent(); @@ -163,7 +168,10 @@ public class MessageSender { } Recipient recipient = message.getRecipient(); - long messageId = mmsDatabase.insertMessageOutbox(message, allocatedThreadId, false, insertListener); + long messageId = mmsDatabase.insertMessageOutbox(applyUniversalExpireTimerIfNecessary(context, recipient, message, allocatedThreadId), + allocatedThreadId, + false, + insertListener); List attachmentIds = Stream.of(preUploadResults).map(PreUploadResult::getAttachmentId).toList(); List jobIds = Stream.of(preUploadResults).map(PreUploadResult::getJobIds).flatMap(Stream::of).toList(); @@ -198,7 +206,10 @@ public class MessageSender { try { OutgoingSecureMediaMessage primaryMessage = messages.get(0); long primaryThreadId = threadDatabase.getThreadIdFor(primaryMessage.getRecipient(), primaryMessage.getDistributionType()); - long primaryMessageId = mmsDatabase.insertMessageOutbox(primaryMessage, primaryThreadId, false, null); + long primaryMessageId = mmsDatabase.insertMessageOutbox(applyUniversalExpireTimerIfNecessary(context, primaryMessage.getRecipient(), primaryMessage, primaryThreadId), + primaryThreadId, + false, + null); attachmentDatabase.updateMessageId(preUploadAttachmentIds, primaryMessageId); messageIds.add(primaryMessageId); @@ -216,7 +227,10 @@ public class MessageSender { for (OutgoingSecureMediaMessage secondaryMessage : secondaryMessages) { long allocatedThreadId = threadDatabase.getThreadIdFor(secondaryMessage.getRecipient(), secondaryMessage.getDistributionType()); - long messageId = mmsDatabase.insertMessageOutbox(secondaryMessage, allocatedThreadId, false, null); + long messageId = mmsDatabase.insertMessageOutbox(applyUniversalExpireTimerIfNecessary(context, secondaryMessage.getRecipient(), secondaryMessage, allocatedThreadId), + allocatedThreadId, + false, + null); List attachmentIds = new ArrayList<>(preUploadAttachmentIds.size()); for (int i = 0; i < preUploadAttachments.size(); i++) { @@ -355,6 +369,20 @@ public class MessageSender { EventBus.getDefault().postSticky(MessageSentEvent.INSTANCE); } + private static @NonNull OutgoingTextMessage applyUniversalExpireTimerIfNecessary(@NonNull Context context, @NonNull Recipient recipient, @NonNull OutgoingTextMessage outgoingTextMessage, long threadId) { + if (outgoingTextMessage.getExpiresIn() == 0 && RecipientUtil.setAndSendUniversalExpireTimerIfNecessary(context, recipient, threadId)) { + return new OutgoingTextMessage(outgoingTextMessage, SignalStore.settings().getUniversalExpireTimer() * 1000L); + } + return outgoingTextMessage; + } + + private static @NonNull OutgoingMediaMessage applyUniversalExpireTimerIfNecessary(@NonNull Context context, @NonNull Recipient recipient, @NonNull OutgoingMediaMessage outgoingMediaMessage, long threadId) { + if (!outgoingMediaMessage.isExpirationUpdate() && outgoingMediaMessage.getExpiresIn() == 0 && RecipientUtil.setAndSendUniversalExpireTimerIfNecessary(context, recipient, threadId)) { + return new OutgoingMediaMessage(outgoingMediaMessage, SignalStore.settings().getUniversalExpireTimer() * 1000L); + } + return outgoingMediaMessage; + } + private static void sendMediaMessage(Context context, Recipient recipient, boolean forceSms, long messageId, @NonNull Collection uploadJobIds) { if (isLocalSelfSend(context, recipient, forceSms)) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/sms/OutgoingTextMessage.java b/app/src/main/java/org/thoughtcrime/securesms/sms/OutgoingTextMessage.java index e74cb8ff9..d22f12f65 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sms/OutgoingTextMessage.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sms/OutgoingTextMessage.java @@ -28,6 +28,10 @@ public class OutgoingTextMessage { this.message = body; } + public OutgoingTextMessage(OutgoingTextMessage base, long expiresIn) { + this(base.getRecipient(), base.getMessageBody(), expiresIn, base.getSubscriptionId()); + } + public long getExpiresIn() { return expiresIn; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/AccountRecordProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/storage/AccountRecordProcessor.java index 9c3f02a47..705abc453 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/AccountRecordProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/AccountRecordProcessor.java @@ -97,9 +97,10 @@ public class AccountRecordProcessor extends DefaultStorageRecordProcessor pinnedConversations = remote.getPinnedConversations(); AccountRecord.PhoneNumberSharingMode phoneNumberSharingMode = remote.getPhoneNumberSharingMode(); boolean preferContactAvatars = remote.isPreferContactAvatars(); + int universalExpireTimer = remote.getUniversalExpireTimer(); boolean primarySendsSms = local.isPrimarySendsSms(); - boolean matchesRemote = doParamsMatch(remote, unknownFields, givenName, familyName, avatarUrlPath, profileKey, noteToSelfArchived, noteToSelfForcedUnread, readReceipts, typingIndicators, sealedSenderIndicators, linkPreviews, phoneNumberSharingMode, unlisted, pinnedConversations, preferContactAvatars, payments, primarySendsSms); - boolean matchesLocal = doParamsMatch(local, unknownFields, givenName, familyName, avatarUrlPath, profileKey, noteToSelfArchived, noteToSelfForcedUnread, readReceipts, typingIndicators, sealedSenderIndicators, linkPreviews, phoneNumberSharingMode, unlisted, pinnedConversations, preferContactAvatars, payments, primarySendsSms); + boolean matchesRemote = doParamsMatch(remote, unknownFields, givenName, familyName, avatarUrlPath, profileKey, noteToSelfArchived, noteToSelfForcedUnread, readReceipts, typingIndicators, sealedSenderIndicators, linkPreviews, phoneNumberSharingMode, unlisted, pinnedConversations, preferContactAvatars, payments, universalExpireTimer, primarySendsSms); + boolean matchesLocal = doParamsMatch(local, unknownFields, givenName, familyName, avatarUrlPath, profileKey, noteToSelfArchived, noteToSelfForcedUnread, readReceipts, typingIndicators, sealedSenderIndicators, linkPreviews, phoneNumberSharingMode, unlisted, pinnedConversations, preferContactAvatars, payments, universalExpireTimer, primarySendsSms); if (matchesRemote) { return remote; @@ -124,6 +125,7 @@ public class AccountRecordProcessor extends DefaultStorageRecordProcessor pinnedConversations, boolean preferContactAvatars, SignalAccountRecord.Payments payments, + int universalExpireTimer, boolean primarySendsSms) { return Arrays.equals(contact.serializeUnknownFields(), unknownFields) && @@ -178,6 +181,7 @@ public class AccountRecordProcessor extends DefaultStorageRecordProcessor LiveData.distinctUntilChanged(selector: (T) -> R): LiveData { + return LiveDataUtil.distinctUntilChanged(this, selector) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/livedata/ProcessState.kt b/app/src/main/java/org/thoughtcrime/securesms/util/livedata/ProcessState.kt new file mode 100644 index 000000000..ff54a77f1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/livedata/ProcessState.kt @@ -0,0 +1,22 @@ +package org.thoughtcrime.securesms.util.livedata + +/** + * Provide a general representation of a discrete process. States are idle, + * working, success, and failure. + */ +sealed class ProcessState { + class Idle : ProcessState() + class Working : ProcessState() + data class Success(val result: T) : ProcessState() + data class Failure(val throwable: Throwable?) : ProcessState() + + companion object { + fun fromResult(result: Result): ProcessState { + return if (result.isSuccess) { + Success(result.getOrThrow()) + } else { + Failure(result.exceptionOrNull()) + } + } + } +} diff --git a/app/src/main/res/layout/add_group_details_fragment.xml b/app/src/main/res/layout/add_group_details_fragment.xml index 00c1b21af..8f579766b 100644 --- a/app/src/main/res/layout/add_group_details_fragment.xml +++ b/app/src/main/res/layout/add_group_details_fragment.xml @@ -39,6 +39,42 @@ app:layout_constraintStart_toEndOf="@id/group_avatar" app:layout_constraintTop_toTopOf="@id/group_avatar" /> + + + + + + + + + + - - + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/custom_expire_timer_selector_view.xml b/app/src/main/res/layout/custom_expire_timer_selector_view.xml new file mode 100644 index 000000000..63aaa4e98 --- /dev/null +++ b/app/src/main/res/layout/custom_expire_timer_selector_view.xml @@ -0,0 +1,19 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dsl_radio_preference_item.xml b/app/src/main/res/layout/dsl_radio_preference_item.xml new file mode 100644 index 000000000..924e03379 --- /dev/null +++ b/app/src/main/res/layout/dsl_radio_preference_item.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/expire_timer_settings_fragment.xml b/app/src/main/res/layout/expire_timer_settings_fragment.xml new file mode 100644 index 000000000..251769830 --- /dev/null +++ b/app/src/main/res/layout/expire_timer_settings_fragment.xml @@ -0,0 +1,33 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/value_click_preference_item.xml b/app/src/main/res/layout/value_click_preference_item.xml new file mode 100644 index 000000000..90c6cf4c4 --- /dev/null +++ b/app/src/main/res/layout/value_click_preference_item.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/app_settings.xml b/app/src/main/res/navigation/app_settings.xml index 3ba93c154..342f82e90 100644 --- a/app/src/main/res/navigation/app_settings.xml +++ b/app/src/main/res/navigation/app_settings.xml @@ -260,6 +260,14 @@ app:exitAnim="@anim/fragment_open_exit" app:popEnterAnim="@anim/fragment_close_enter" app:popExitAnim="@anim/fragment_close_exit" /> + + + + diff --git a/app/src/main/res/navigation/app_settings_expire_timer.xml b/app/src/main/res/navigation/app_settings_expire_timer.xml new file mode 100644 index 000000000..658a2719f --- /dev/null +++ b/app/src/main/res/navigation/app_settings_expire_timer.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index a247be719..ad913e201 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -360,4 +360,34 @@ @string/MediaOverviewActivity_Storage_used + + @string/ExpireTimerSettingsFragment__off + @string/ExpireTimerSettingsFragment__4_weeks + @string/ExpireTimerSettingsFragment__1_week + @string/ExpireTimerSettingsFragment__1_day + @string/ExpireTimerSettingsFragment__8_hours + @string/ExpireTimerSettingsFragment__1_hour + @string/ExpireTimerSettingsFragment__5_minutes + @string/ExpireTimerSettingsFragment__30_seconds + + + + 0 + 2419200 + 604800 + 86400 + 28800 + 3600 + 300 + 30 + + + + @string/CustomExpireTimerSelectorView__second + @string/CustomExpireTimerSelectorView__minute + @string/CustomExpireTimerSelectorView__hour + @string/CustomExpireTimerSelectorView__day + @string/CustomExpireTimerSelectorView__week + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3da51b48a..52bf430d3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1907,6 +1907,7 @@ No groups in common. Review requests carefully. No contacts in this group. Review requests carefully. View + The disappearing message time will be set to %1$s when you message them. Play … Pause @@ -3415,15 +3416,39 @@ Blocked %1$d contacts Messaging + Disappearing messages App security Block screenshots in the recents list and inside the app Signal messages and calls, always relay calls, and sealed sender + Default timer for new chats + Set a default disappearing message timer for all new chats started by you. https://signal.org/blog/sealed-sender Show status icon Show an icon in message details when they were delivered using sealed sender. + + When enabled, new messages sent and received in new chats started by you will disappear after they have been seen. + When enabled, new messages sent and received in this chat will disappear after they have been seen. + Off + 4 weeks + 1 week + 1 day + 8 hours + 1 hour + 5 minutes + 30 seconds + Custom time + Set + Save + + Second + Minute + Hour + Day + Week + Support center Contact us diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalAccountRecord.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalAccountRecord.java index d89d76d44..8a1fc93f6 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalAccountRecord.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalAccountRecord.java @@ -127,6 +127,10 @@ public final class SignalAccountRecord implements SignalRecord { diff.add("Payments"); } + if (this.getUniversalExpireTimer() != that.getUniversalExpireTimer()) { + diff.add("UniversalExpireTimer"); + } + if (!Objects.equals(this.isPrimarySendsSms(), that.isPrimarySendsSms())) { diff.add("PrimarySendsSms"); } @@ -209,6 +213,10 @@ public final class SignalAccountRecord implements SignalRecord { return payments; } + public int getUniversalExpireTimer() { + return proto.getUniversalExpireTimer(); + } + public boolean isPrimarySendsSms() { return proto.getPrimarySendsSms(); } @@ -467,6 +475,11 @@ public final class SignalAccountRecord implements SignalRecord { return this; } + public Builder setUniversalExpireTimer(int timer) { + builder.setUniversalExpireTimer(timer); + return this; + } + public Builder setPrimarySendsSms(boolean primarySendsSms) { builder.setPrimarySendsSms(primarySendsSms); return this; diff --git a/libsignal/service/src/main/proto/SignalStorage.proto b/libsignal/service/src/main/proto/SignalStorage.proto index b9af99e22..64d2bc71a 100644 --- a/libsignal/service/src/main/proto/SignalStorage.proto +++ b/libsignal/service/src/main/proto/SignalStorage.proto @@ -144,5 +144,6 @@ message AccountRecord { repeated PinnedConversation pinnedConversations = 14; bool preferContactAvatars = 15; Payments payments = 16; + uint32 universalExpireTimer = 17; bool primarySendsSms = 18; }