Add phased SMS removal UX.

This commit is contained in:
Cody Henthorne
2022-10-13 11:33:13 -04:00
committed by Greyson Parrelli
parent 8a238a66e7
commit b6db7e7af6
68 changed files with 1214 additions and 187 deletions

View File

@@ -6,10 +6,14 @@ import android.view.View
import android.view.View.OnLongClickListener
import android.view.ViewGroup
import androidx.appcompat.widget.AppCompatImageButton
import com.google.android.material.snackbar.Snackbar
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
import org.thoughtcrime.securesms.conversation.MessageSendType
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.ViewUtil
import java.lang.AssertionError
import java.util.concurrent.CopyOnWriteArrayList
@@ -30,6 +34,8 @@ class SendButton(context: Context, attributeSet: AttributeSet?) : AppCompatImage
private var activeMessageSendType: MessageSendType? = null
private var defaultTransportType: MessageSendType.TransportType = MessageSendType.TransportType.SMS
private var defaultSubscriptionId: Int? = null
lateinit var snackbarContainer: View
private var popupContainer: ViewGroup? = null
init {
@@ -146,10 +152,19 @@ class SendButton(context: Context, attributeSet: AttributeSet?) : AppCompatImage
}
override fun onLongClick(v: View): Boolean {
if (!isEnabled || availableSendTypes.size == 1) {
if (!isEnabled) {
return false
}
if (availableSendTypes.size == 1) {
return if (!Util.isDefaultSmsProvider(context) || !SignalStore.misc().smsExportPhase.isSmsSupported()) {
Snackbar.make(snackbarContainer, R.string.InputPanel__sms_messaging_is_no_longer_supported_in_signal, Snackbar.LENGTH_SHORT).show()
true
} else {
false
}
}
val currentlySelected: MessageSendType = selectedSendType
val items = availableSendTypes

View File

@@ -1,28 +1,41 @@
package org.thoughtcrime.securesms.components.settings.app.chats
import android.app.Activity
import android.content.Intent
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.Navigation
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.chats.sms.SmsExportState
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.exporter.flow.SmsExportActivity
import org.thoughtcrime.securesms.exporter.flow.SmsExportDialogs
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.navigation.safeNavigate
class ChatsSettingsFragment : DSLSettingsFragment(R.string.preferences_chats__chats) {
private lateinit var viewModel: ChatsSettingsViewModel
private lateinit var smsExportLauncher: ActivityResultLauncher<Intent>
override fun onResume() {
super.onResume()
viewModel.refresh()
}
@Suppress("ReplaceGetOrSet")
override fun bindAdapter(adapter: MappingAdapter) {
val repository = ChatsSettingsRepository()
val factory = ChatsSettingsViewModel.Factory(repository)
viewModel = ViewModelProvider(this, factory)[ChatsSettingsViewModel::class.java]
smsExportLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == Activity.RESULT_OK) {
SmsExportDialogs.showSmsRemovalDialog(requireContext(), requireView())
}
}
viewModel = ViewModelProvider(this).get(ChatsSettingsViewModel::class.java)
viewModel.state.observe(viewLifecycleOwner) {
adapter.submitList(getConfiguration(it).toMappingModelList())
@@ -32,14 +45,46 @@ class ChatsSettingsFragment : DSLSettingsFragment(R.string.preferences_chats__ch
private fun getConfiguration(state: ChatsSettingsState): DSLConfiguration {
return configure {
clickPref(
title = DSLSettingsText.from(R.string.preferences__sms_mms),
onClick = {
Navigation.findNavController(requireView()).safeNavigate(R.id.action_chatsSettingsFragment_to_smsSettingsFragment)
}
)
if (!state.useAsDefaultSmsApp) {
when (state.smsExportState) {
SmsExportState.FETCHING -> Unit
SmsExportState.HAS_UNEXPORTED_MESSAGES -> {
clickPref(
title = DSLSettingsText.from(R.string.SmsSettingsFragment__export_sms_messages),
summary = DSLSettingsText.from(R.string.SmsSettingsFragment__you_can_export_your_sms_messages_to_your_phones_sms_database),
onClick = {
smsExportLauncher.launch(SmsExportActivity.createIntent(requireContext()))
}
)
dividerPref()
dividerPref()
}
SmsExportState.ALL_MESSAGES_EXPORTED -> {
clickPref(
title = DSLSettingsText.from(R.string.SmsSettingsFragment__remove_sms_messages),
summary = DSLSettingsText.from(R.string.SmsSettingsFragment__remove_sms_messages_from_signal_to_clear_up_storage_space),
onClick = {
SmsExportDialogs.showSmsRemovalDialog(requireContext(), requireView())
}
)
dividerPref()
}
SmsExportState.NO_SMS_MESSAGES_IN_DATABASE -> Unit
SmsExportState.NOT_AVAILABLE -> Unit
}
}
if (state.useAsDefaultSmsApp) {
clickPref(
title = DSLSettingsText.from(R.string.preferences__sms_mms),
onClick = {
Navigation.findNavController(requireView()).safeNavigate(R.id.action_chatsSettingsFragment_to_smsSettingsFragment)
}
)
dividerPref()
}
switchPref(
title = DSLSettingsText.from(R.string.preferences__generate_link_previews),

View File

@@ -1,9 +1,13 @@
package org.thoughtcrime.securesms.components.settings.app.chats
import org.thoughtcrime.securesms.components.settings.app.chats.sms.SmsExportState
data class ChatsSettingsState(
val generateLinkPreviews: Boolean,
val useAddressBook: Boolean,
val useSystemEmoji: Boolean,
val enterKeySends: Boolean,
val chatBackupsEnabled: Boolean
val chatBackupsEnabled: Boolean,
val useAsDefaultSmsApp: Boolean,
val smsExportState: SmsExportState = SmsExportState.FETCHING
)

View File

@@ -2,17 +2,24 @@ package org.thoughtcrime.securesms.components.settings.app.chats
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import org.thoughtcrime.securesms.components.settings.app.chats.sms.SmsSettingsRepository
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.BackupUtil
import org.thoughtcrime.securesms.util.ConversationUtil
import org.thoughtcrime.securesms.util.ThrottledDebouncer
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.livedata.Store
class ChatsSettingsViewModel(private val repository: ChatsSettingsRepository) : ViewModel() {
class ChatsSettingsViewModel @JvmOverloads constructor(
private val repository: ChatsSettingsRepository = ChatsSettingsRepository(),
smsSettingsRepository: SmsSettingsRepository = SmsSettingsRepository()
) : ViewModel() {
private val refreshDebouncer = ThrottledDebouncer(500L)
private val disposables = CompositeDisposable()
private val store: Store<ChatsSettingsState> = Store(
ChatsSettingsState(
@@ -20,12 +27,23 @@ class ChatsSettingsViewModel(private val repository: ChatsSettingsRepository) :
useAddressBook = SignalStore.settings().isPreferSystemContactPhotos,
useSystemEmoji = SignalStore.settings().isPreferSystemEmoji,
enterKeySends = SignalStore.settings().isEnterKeySends,
chatBackupsEnabled = SignalStore.settings().isBackupEnabled && BackupUtil.canUserAccessBackupDirectory(ApplicationDependencies.getApplication())
chatBackupsEnabled = SignalStore.settings().isBackupEnabled && BackupUtil.canUserAccessBackupDirectory(ApplicationDependencies.getApplication()),
useAsDefaultSmsApp = Util.isDefaultSmsProvider(ApplicationDependencies.getApplication())
)
)
val state: LiveData<ChatsSettingsState> = store.stateLiveData
init {
disposables += smsSettingsRepository.getSmsExportState().subscribe { state ->
store.update { it.copy(smsExportState = state) }
}
}
override fun onCleared() {
disposables.clear()
}
fun setGenerateLinkPreviewsEnabled(enabled: Boolean) {
store.update { it.copy(generateLinkPreviews = enabled) }
SignalStore.settings().isLinkPreviewsEnabled = enabled
@@ -55,10 +73,4 @@ class ChatsSettingsViewModel(private val repository: ChatsSettingsRepository) :
store.update { it.copy(chatBackupsEnabled = backupsEnabled) }
}
}
class Factory(private val repository: ChatsSettingsRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(ChatsSettingsViewModel(repository)))
}
}
}

View File

@@ -0,0 +1,9 @@
package org.thoughtcrime.securesms.components.settings.app.chats.sms
enum class SmsExportState {
FETCHING,
HAS_UNEXPORTED_MESSAGES,
ALL_MESSAGES_EXPORTED,
NO_SMS_MESSAGES_IN_DATABASE,
NOT_AVAILABLE
}

View File

@@ -9,18 +9,16 @@ import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.Navigation
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import org.signal.core.util.concurrent.SignalExecutors
import androidx.navigation.fragment.findNavController
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.components.settings.models.OutlinedLearnMore
import org.thoughtcrime.securesms.exporter.flow.SmsExportActivity
import org.thoughtcrime.securesms.exporter.flow.SmsExportDialogs
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.SmsUtil
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.navigation.safeNavigate
@@ -38,9 +36,11 @@ class SmsSettingsFragment : DSLSettingsFragment(R.string.preferences__sms_mms) {
}
override fun bindAdapter(adapter: MappingAdapter) {
OutlinedLearnMore.register(adapter)
smsExportLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == Activity.RESULT_OK) {
showSmsRemovalDialog()
SmsExportDialogs.showSmsRemovalDialog(requireContext(), requireView())
}
}
@@ -52,16 +52,32 @@ class SmsSettingsFragment : DSLSettingsFragment(R.string.preferences__sms_mms) {
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
SignalStore.settings().setDefaultSms(Util.isDefaultSmsProvider(requireContext()))
if (Util.isDefaultSmsProvider(requireContext())) {
SignalStore.settings().setDefaultSms(true)
} else {
SignalStore.settings().setDefaultSms(false)
findNavController().navigateUp()
}
}
private fun getConfiguration(state: SmsSettingsState): DSLConfiguration {
return configure {
if (state.useAsDefaultSmsApp) {
customPref(
OutlinedLearnMore.Model(
summary = DSLSettingsText.from(R.string.SmsSettingsFragment__sms_support_will_be_removed_soon_to_focus_on_encrypted_messaging),
learnMoreUrl = getString(R.string.sms_export_url)
)
)
}
when (state.smsExportState) {
SmsSettingsState.SmsExportState.FETCHING -> Unit
SmsSettingsState.SmsExportState.HAS_UNEXPORTED_MESSAGES -> {
SmsExportState.FETCHING -> Unit
SmsExportState.HAS_UNEXPORTED_MESSAGES -> {
clickPref(
title = DSLSettingsText.from(R.string.SmsSettingsFragment__export_sms_messages),
summary = DSLSettingsText.from(R.string.SmsSettingsFragment__you_can_export_your_sms_messages_to_your_phones_sms_database),
onClick = {
smsExportLauncher.launch(SmsExportActivity.createIntent(requireContext()))
}
@@ -69,32 +85,31 @@ class SmsSettingsFragment : DSLSettingsFragment(R.string.preferences__sms_mms) {
dividerPref()
}
SmsSettingsState.SmsExportState.ALL_MESSAGES_EXPORTED -> {
SmsExportState.ALL_MESSAGES_EXPORTED -> {
clickPref(
title = DSLSettingsText.from(R.string.SmsSettingsFragment__remove_sms_messages),
summary = DSLSettingsText.from(R.string.SmsSettingsFragment__remove_sms_messages_from_signal_to_clear_up_storage_space),
onClick = {
showSmsRemovalDialog()
SmsExportDialogs.showSmsRemovalDialog(requireContext(), requireView())
}
)
dividerPref()
}
SmsSettingsState.SmsExportState.NO_SMS_MESSAGES_IN_DATABASE -> Unit
SmsSettingsState.SmsExportState.NOT_AVAILABLE -> Unit
SmsExportState.NO_SMS_MESSAGES_IN_DATABASE -> Unit
SmsExportState.NOT_AVAILABLE -> Unit
}
@Suppress("DEPRECATION")
clickPref(
title = DSLSettingsText.from(R.string.SmsSettingsFragment__use_as_default_sms_app),
summary = DSLSettingsText.from(if (state.useAsDefaultSmsApp) R.string.arrays__enabled else R.string.arrays__disabled),
onClick = {
if (state.useAsDefaultSmsApp) {
if (state.useAsDefaultSmsApp) {
@Suppress("DEPRECATION")
clickPref(
title = DSLSettingsText.from(R.string.SmsSettingsFragment__use_as_default_sms_app),
summary = DSLSettingsText.from(R.string.arrays__enabled),
onClick = {
startDefaultAppSelectionIntent()
} else {
startActivityForResult(SmsUtil.getSmsRoleIntent(requireContext()), SMS_REQUEST_CODE.toInt())
}
}
)
)
}
switchPref(
title = DSLSettingsText.from(R.string.preferences__sms_delivery_reports),
@@ -137,21 +152,4 @@ class SmsSettingsFragment : DSLSettingsFragment(R.string.preferences__sms_mms) {
startActivityForResult(intent, SMS_REQUEST_CODE.toInt())
}
private fun showSmsRemovalDialog() {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.RemoveSmsMessagesDialogFragment__remove_sms_messages)
.setMessage(R.string.RemoveSmsMessagesDialogFragment__you_can_now_remove_sms_messages_from_signal)
.setPositiveButton(R.string.RemoveSmsMessagesDialogFragment__keep_messages) { _, _ ->
Snackbar.make(requireView(), R.string.SmsSettingsFragment__you_can_remove_sms_messages_from_signal_in_settings, Snackbar.LENGTH_SHORT).show()
}
.setNegativeButton(R.string.RemoveSmsMessagesDialogFragment__remove_messages) { _, _ ->
SignalExecutors.BOUNDED.execute {
SignalDatabase.sms.deleteExportedMessages()
SignalDatabase.mms.deleteExportedMessages()
}
Snackbar.make(requireView(), R.string.SmsSettingsFragment__removing_sms_messages_from_signal, Snackbar.LENGTH_SHORT).show()
}
.show()
}
}

View File

@@ -11,9 +11,9 @@ class SmsSettingsRepository(
private val smsDatabase: MessageDatabase = SignalDatabase.sms,
private val mmsDatabase: MessageDatabase = SignalDatabase.mms
) {
fun getSmsExportState(): Single<SmsSettingsState.SmsExportState> {
fun getSmsExportState(): Single<SmsExportState> {
if (!FeatureFlags.smsExporter()) {
return Single.just(SmsSettingsState.SmsExportState.NOT_AVAILABLE)
return Single.just(SmsExportState.NOT_AVAILABLE)
}
return Single.fromCallable {
@@ -22,24 +22,24 @@ class SmsSettingsRepository(
}
@WorkerThread
private fun checkInsecureMessageCount(): SmsSettingsState.SmsExportState? {
private fun checkInsecureMessageCount(): SmsExportState? {
val totalSmsMmsCount = smsDatabase.insecureMessageCount + mmsDatabase.insecureMessageCount
return if (totalSmsMmsCount == 0) {
SmsSettingsState.SmsExportState.NO_SMS_MESSAGES_IN_DATABASE
SmsExportState.NO_SMS_MESSAGES_IN_DATABASE
} else {
null
}
}
@WorkerThread
private fun checkUnexportedInsecureMessageCount(): SmsSettingsState.SmsExportState {
private fun checkUnexportedInsecureMessageCount(): SmsExportState {
val totalUnexportedCount = smsDatabase.unexportedInsecureMessagesCount + mmsDatabase.unexportedInsecureMessagesCount
return if (totalUnexportedCount > 0) {
SmsSettingsState.SmsExportState.HAS_UNEXPORTED_MESSAGES
SmsExportState.HAS_UNEXPORTED_MESSAGES
} else {
SmsSettingsState.SmsExportState.ALL_MESSAGES_EXPORTED
SmsExportState.ALL_MESSAGES_EXPORTED
}
}
}

View File

@@ -5,12 +5,4 @@ data class SmsSettingsState(
val smsDeliveryReportsEnabled: Boolean,
val wifiCallingCompatibilityEnabled: Boolean,
val smsExportState: SmsExportState = SmsExportState.FETCHING
) {
enum class SmsExportState {
FETCHING,
HAS_UNEXPORTED_MESSAGES,
ALL_MESSAGES_EXPORTED,
NO_SMS_MESSAGES_IN_DATABASE,
NOT_AVAILABLE
}
}
)

View File

@@ -15,6 +15,7 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ContactFilterView
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader
import org.thoughtcrime.securesms.groups.SelectionLimits
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.Util
@@ -105,7 +106,7 @@ class SelectRecipientsFragment : LoggingFragment(), ContactSelectionListFragment
ContactsCursorLoader.DisplayMode.FLAG_HIDE_RECENT_HEADER or
ContactsCursorLoader.DisplayMode.FLAG_GROUPS_AFTER_CONTACTS
if (Util.isDefaultSmsProvider(requireContext())) {
if (Util.isDefaultSmsProvider(requireContext()) && SignalStore.misc().smsExportPhase.isSmsSupported()) {
mode = mode or ContactsCursorLoader.DisplayMode.FLAG_SMS
}

View File

@@ -16,14 +16,17 @@ import org.thoughtcrime.securesms.components.settings.conversation.preferences.L
import org.thoughtcrime.securesms.database.AttachmentDatabase
import org.thoughtcrime.securesms.database.RecipientDatabase
import org.thoughtcrime.securesms.database.model.StoryViewState
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.LiveGroup
import org.thoughtcrime.securesms.groups.v2.GroupAddMembersResult
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.recipients.RecipientUtil
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.SingleLiveEvent
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil
import org.thoughtcrime.securesms.util.livedata.Store
import java.util.Optional
@@ -138,11 +141,17 @@ sealed class ConversationSettingsViewModel(
}
store.update(liveRecipient.liveData) { recipient, state ->
val isAudioAvailable = (recipient.isRegistered || (Util.isDefaultSmsProvider(ApplicationDependencies.getApplication()) && SignalStore.misc().smsExportPhase.isSmsSupported())) &&
!recipient.isGroup &&
!recipient.isBlocked &&
!recipient.isSelf &&
!recipient.isReleaseNotes
state.copy(
recipient = recipient,
buttonStripState = ButtonStripPreference.State(
isVideoAvailable = recipient.registered == RecipientDatabase.RegisteredState.REGISTERED && !recipient.isSelf && !recipient.isBlocked && !recipient.isReleaseNotes,
isAudioAvailable = !recipient.isGroup && !recipient.isSelf && !recipient.isBlocked && !recipient.isReleaseNotes,
isAudioAvailable = isAudioAvailable,
isAudioSecure = recipient.registered == RecipientDatabase.RegisteredState.REGISTERED,
isMuted = recipient.isMuted,
isMuteAvailable = !recipient.isSelf,

View File

@@ -0,0 +1,35 @@
package org.thoughtcrime.securesms.components.settings.models
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.databinding.DslOutlinedLearnMoreBinding
import org.thoughtcrime.securesms.util.adapter.mapping.BindingFactory
import org.thoughtcrime.securesms.util.adapter.mapping.BindingViewHolder
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
/**
* Show a informational text message in an outlined bubble.
*/
object OutlinedLearnMore {
fun register(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(Model::class.java, BindingFactory(::ViewHolder, DslOutlinedLearnMoreBinding::inflate))
}
class Model(
summary: DSLSettingsText,
val learnMoreUrl: String
) : PreferenceModel<Model>(summary = summary) {
override fun areContentsTheSame(newItem: Model): Boolean {
return super.areContentsTheSame(newItem) && learnMoreUrl == newItem.learnMoreUrl
}
}
private class ViewHolder(binding: DslOutlinedLearnMoreBinding) : BindingViewHolder<Model, DslOutlinedLearnMoreBinding>(binding) {
override fun bind(model: Model) {
binding.root.text = model.summary!!.resolve(context)
binding.root.setLearnMoreVisible(true)
binding.root.setLink(model.learnMoreUrl)
}
}
}