Signal-Android/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragment.kt

543 wiersze
22 KiB
Kotlin

package org.thoughtcrime.securesms.conversation.mutiselect.forward
import android.content.res.ColorStateList
import android.graphics.Rect
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.LayoutInflater
import android.view.TouchDelegate
import android.view.View
import android.view.ViewGroup
import android.view.animation.AnimationUtils
import android.widget.EditText
import android.widget.TextView
import android.widget.Toast
import androidx.annotation.PluralsRes
import androidx.annotation.StringRes
import androidx.appcompat.widget.AppCompatImageView
import androidx.core.os.bundleOf
import androidx.core.view.ViewCompat
import androidx.core.view.doOnNextLayout
import androidx.core.view.isVisible
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.setFragmentResultListener
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.color.ViewColorSet
import org.thoughtcrime.securesms.components.ContactFilterView
import org.thoughtcrime.securesms.components.TooltipPopup
import org.thoughtcrime.securesms.components.WrapperDialogFragment
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
import org.thoughtcrime.securesms.contacts.paged.ContactSearchError
import org.thoughtcrime.securesms.contacts.paged.ContactSearchItems
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.contacts.paged.ContactSearchMediator
import org.thoughtcrime.securesms.contacts.paged.ContactSearchState
import org.thoughtcrime.securesms.database.model.IdentityRecord
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mediasend.v2.stories.ChooseGroupStoryBottomSheet
import org.thoughtcrime.securesms.mediasend.v2.stories.ChooseStoryTypeBottomSheet
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet
import org.thoughtcrime.securesms.sharing.ShareSelectionAdapter
import org.thoughtcrime.securesms.sharing.ShareSelectionMappingModel
import org.thoughtcrime.securesms.stories.GroupStoryEducationSheet
import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.stories.Stories.getHeaderAction
import org.thoughtcrime.securesms.stories.settings.create.CreateStoryFlowDialogFragment
import org.thoughtcrime.securesms.stories.settings.create.CreateStoryWithViewersFragment
import org.thoughtcrime.securesms.stories.settings.privacy.ChooseInitialMyStoryMembershipBottomSheetDialogFragment
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.FullscreenHelper
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.fragments.findListener
import org.thoughtcrime.securesms.util.fragments.requireListener
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog
import org.thoughtcrime.securesms.util.visible
/**
* Allows selection and optional sending to one or more users.
*
* This fragment is designed to be displayed in a Dialog fragment, and thus has two available constructors to display as a bottom sheet or full screen dialog.
*
* To customize the available recipients, a parent must implement `SearchConfigurationProvider`
*
* This fragment will emit one of two possible result values at the same key, `RESULT_KEY`:
*
* - If the arguments contain a non-empty list of MultiShareArgs, then messages will be sent when the selection is confirmed. This will result in `RESULT_SENT` being true.
* - If the arguments contain an empty list of MultiShareArgs, then the selection will be returned on confirmation. This will result in `RESULT_SELECTION` being set.
*
* It is up to the user of this fragment to handle the result accordingly utilizing a fragment result listener.
*/
class MultiselectForwardFragment :
Fragment(R.layout.multiselect_forward_fragment),
SafetyNumberBottomSheet.Callbacks,
ChooseStoryTypeBottomSheet.Callback,
GroupStoryEducationSheet.Callback,
WrapperDialogFragment.WrapperDialogFragmentCallback,
ChooseInitialMyStoryMembershipBottomSheetDialogFragment.Callback {
private val viewModel: MultiselectForwardViewModel by viewModels(factoryProducer = this::createViewModelFactory)
private val disposables = LifecycleDisposable()
private lateinit var contactFilterView: ContactFilterView
private lateinit var addMessage: EditText
private lateinit var contactSearchMediator: ContactSearchMediator
private lateinit var contactSearchRecycler: RecyclerView
private lateinit var callback: Callback
private var dismissibleDialog: SimpleProgressDialog.DismissibleDialog? = null
private var handler: Handler? = null
private fun createViewModelFactory(): MultiselectForwardViewModel.Factory {
return MultiselectForwardViewModel.Factory(args.storySendRequirements, args.multiShareArgs, args.forceSelectionOnly, MultiselectForwardRepository())
}
private val args: MultiselectForwardFragmentArgs by lazy {
requireArguments().getParcelable(ARGS)!!
}
override fun onGetLayoutInflater(savedInstanceState: Bundle?): LayoutInflater {
return if (parentFragment != null) {
requireParentFragment().onGetLayoutInflater(savedInstanceState)
} else {
super.onGetLayoutInflater(savedInstanceState)
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
view.minimumHeight = resources.displayMetrics.heightPixels
contactSearchRecycler = view.findViewById(R.id.contact_selection_list)
contactSearchMediator = ContactSearchMediator(this, contactSearchRecycler, FeatureFlags.shareSelectionLimit(), !args.selectSingleRecipient, ContactSearchItems.DisplaySmsTag.DEFAULT, this::getConfiguration, this::filterContacts)
callback = findListener()!!
disposables.bindTo(viewLifecycleOwner.lifecycle)
contactFilterView = view.findViewById(R.id.contact_filter_edit_text)
contactFilterView.visible = args.isSearchEnabled
contactFilterView.setOnSearchInputFocusChangedListener { _, hasFocus ->
if (hasFocus) {
callback.onSearchInputFocused()
}
}
contactFilterView.setOnFilterChangedListener {
contactSearchMediator.onFilterChanged(it)
}
val container = callback.getContainer()
val title: TextView? = container.findViewById(R.id.title)
val bottomBarAndSpacer = LayoutInflater.from(requireContext()).inflate(R.layout.multiselect_forward_fragment_bottom_bar_and_spacer, container, false)
val bottomBar: ViewGroup = bottomBarAndSpacer.findViewById(R.id.bottom_bar)
val bottomBarSpacer: View = bottomBarAndSpacer.findViewById(R.id.bottom_bar_spacer)
val shareSelectionRecycler: RecyclerView = bottomBar.findViewById(R.id.selected_list)
val shareSelectionAdapter = ShareSelectionAdapter()
val sendButtonFrame: View = bottomBar.findViewById(R.id.share_confirm_frame)
val sendButton: AppCompatImageView = bottomBar.findViewById(R.id.share_confirm)
val backgroundHelper: View = bottomBar.findViewById(R.id.background_helper)
val sendButtonColors: ViewColorSet = args.sendButtonColors
sendButton.setColorFilter(sendButtonColors.foreground.resolve(requireContext()))
ViewCompat.setBackgroundTintList(sendButton, ColorStateList.valueOf(sendButtonColors.background.resolve(requireContext())))
FullscreenHelper.configureBottomBarLayout(requireActivity(), bottomBarSpacer, bottomBar)
backgroundHelper.setBackgroundColor(callback.getDialogBackgroundColor())
bottomBarSpacer.setBackgroundColor(callback.getDialogBackgroundColor())
title?.setText(args.title)
addMessage = bottomBar.findViewById(R.id.add_message)
sendButton.doOnNextLayout {
val rect = Rect()
sendButton.getHitRect(rect)
rect.top -= sendButtonFrame.paddingTop
rect.left -= sendButtonFrame.paddingStart
rect.right += sendButtonFrame.paddingEnd
rect.bottom += sendButtonFrame.paddingBottom
sendButtonFrame.touchDelegate = TouchDelegate(rect, sendButton)
}
sendButton.setOnClickListener {
ViewUtil.hideKeyboard(requireContext(), it)
onSend(it)
}
sendButton.visible = !args.selectSingleRecipient
shareSelectionRecycler.adapter = shareSelectionAdapter
bottomBar.visible = false
container.addView(bottomBarAndSpacer)
contactSearchMediator.getSelectionState().observe(viewLifecycleOwner) { contactSelection ->
if (contactSelection.isNotEmpty() && args.selectSingleRecipient) {
onSend(sendButton)
return@observe
}
shareSelectionAdapter.submitList(contactSelection.mapIndexed { index, key -> ShareSelectionMappingModel(key.requireShareContact(), index == 0) })
addMessage.visible = !args.forceDisableAddMessage && contactSelection.any { key -> !key.requireRecipientSearchKey().isStory } && args.multiShareArgs.isNotEmpty()
if (contactSelection.isNotEmpty() && !bottomBar.isVisible) {
bottomBar.animation = AnimationUtils.loadAnimation(requireContext(), R.anim.slide_fade_from_bottom)
bottomBar.visible = true
} else if (contactSelection.isEmpty() && bottomBar.isVisible) {
bottomBar.animation = AnimationUtils.loadAnimation(requireContext(), R.anim.slide_fade_to_bottom)
bottomBar.visible = false
}
}
disposables += contactSearchMediator
.getErrorEvents()
.subscribe {
@Suppress("WHEN_ENUM_CAN_BE_NULL_IN_JAVA")
val message: Int = when (it) {
ContactSearchError.CONTACT_NOT_SELECTABLE -> R.string.MultiselectForwardFragment__only_admins_can_send_messages_to_this_group
ContactSearchError.RECOMMENDED_LIMIT_REACHED -> R.string.ContactSelectionListFragment_recommended_member_limit_reached
ContactSearchError.HARD_LIMIT_REACHED -> R.string.MultiselectForwardFragment__limit_reached
}
Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show()
}
viewModel.state.observe(viewLifecycleOwner) {
when (it.stage) {
MultiselectForwardState.Stage.Selection -> {}
MultiselectForwardState.Stage.FirstConfirmation -> displayFirstSendConfirmation()
is MultiselectForwardState.Stage.SafetyConfirmation -> displaySafetyNumberConfirmation(it.stage.identities, it.stage.selectedContacts)
MultiselectForwardState.Stage.LoadingIdentities -> {}
MultiselectForwardState.Stage.SendPending -> {
handler?.removeCallbacksAndMessages(null)
dismissibleDialog?.dismiss()
dismissibleDialog = SimpleProgressDialog.showDelayed(requireContext())
}
MultiselectForwardState.Stage.SomeFailed -> dismissWithSuccess(R.plurals.MultiselectForwardFragment_messages_sent)
MultiselectForwardState.Stage.AllFailed -> dismissAndShowToast(R.plurals.MultiselectForwardFragment_messages_failed_to_send)
MultiselectForwardState.Stage.Success -> dismissWithSuccess(R.plurals.MultiselectForwardFragment_messages_sent)
is MultiselectForwardState.Stage.SelectionConfirmed -> dismissWithSelection(it.stage.selectedContacts)
}
sendButton.isEnabled = it.stage == MultiselectForwardState.Stage.Selection
}
setFragmentResultListener(CreateStoryWithViewersFragment.REQUEST_KEY) { _, bundle ->
val recipientId: RecipientId = bundle.getParcelable(CreateStoryWithViewersFragment.STORY_RECIPIENT)!!
contactSearchMediator.setKeysSelected(setOf(ContactSearchKey.RecipientSearchKey(recipientId, true)))
contactFilterView.clear()
}
setFragmentResultListener(ChooseGroupStoryBottomSheet.GROUP_STORY) { _, bundle ->
val groups: Set<RecipientId> = bundle.getParcelableArrayList<RecipientId>(ChooseGroupStoryBottomSheet.RESULT_SET)?.toSet() ?: emptySet()
val keys: Set<ContactSearchKey.RecipientSearchKey> = groups.map { ContactSearchKey.RecipientSearchKey(it, true) }.toSet()
contactSearchMediator.addToVisibleGroupStories(keys)
contactSearchMediator.setKeysSelected(keys)
contactFilterView.clear()
}
}
override fun onResume() {
super.onResume()
val now = System.currentTimeMillis()
val expiringMessages = args.multiShareArgs.filter { it.expiresAt > 0L }
val firstToExpire = expiringMessages.minByOrNull { it.expiresAt }
val earliestExpiration = firstToExpire?.expiresAt ?: -1L
if (viewModel.state.value?.stage is MultiselectForwardState.Stage.SelectionConfirmed && contactSearchMediator.getSelectedContacts().isNotEmpty()) {
onCanceled()
}
if (earliestExpiration > 0) {
if (earliestExpiration <= now) {
handleMessageExpired()
} else {
handler = Handler(Looper.getMainLooper())
handler?.postDelayed(this::handleMessageExpired, earliestExpiration - now)
}
}
}
override fun onPause() {
super.onPause()
handler?.removeCallbacksAndMessages(null)
}
override fun onDestroyView() {
dismissibleDialog?.dismissNow()
super.onDestroyView()
}
private fun displayFirstSendConfirmation() {
SignalStore.tooltips().markMultiForwardDialogSeen()
val messageCount = getMessageCount()
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.MultiselectForwardFragment__faster_forwards)
.setMessage(R.string.MultiselectForwardFragment__forwarded_messages_are_now)
.setPositiveButton(resources.getQuantityString(R.plurals.MultiselectForwardFragment_send_d_messages, messageCount, messageCount)) { d, _ ->
d.dismiss()
viewModel.confirmFirstSend(addMessage.text.toString(), contactSearchMediator.getSelectedContacts())
}
.setNegativeButton(android.R.string.cancel) { d, _ ->
d.dismiss()
viewModel.cancelSend()
}
.show()
}
private fun onSend(sendButton: View) {
sendButton.isEnabled = false
viewModel.send(addMessage.text.toString(), contactSearchMediator.getSelectedContacts())
}
private fun displaySafetyNumberConfirmation(identityRecords: List<IdentityRecord>, selectedContacts: List<ContactSearchKey>) {
SafetyNumberBottomSheet
.forIdentityRecordsAndDestinations(identityRecords, selectedContacts)
.show(childFragmentManager)
}
private fun dismissWithSuccess(@PluralsRes toastTextResId: Int) {
requireListener<Callback>().setResult(
Bundle().apply {
putBoolean(RESULT_SENT, true)
}
)
dismissAndShowToast(toastTextResId)
}
private fun dismissAndShowToast(@PluralsRes toastTextResId: Int) {
Log.d(TAG, "dismissAndShowToast")
val argCount = getMessageCount()
callback.onFinishForwardAction()
dismissibleDialog?.dismiss()
Toast.makeText(requireContext(), requireContext().resources.getQuantityString(toastTextResId, argCount), Toast.LENGTH_SHORT).show()
callback.exitFlow()
}
private fun getMessageCount(): Int = args.multiShareArgs.size + if (addMessage.text.isNotEmpty()) 1 else 0
private fun handleMessageExpired() {
Log.d(TAG, "handleMessageExpired")
callback.onFinishForwardAction()
dismissibleDialog?.dismiss()
Toast.makeText(requireContext(), resources.getQuantityString(R.plurals.MultiselectForwardFragment__couldnt_forward_messages, args.multiShareArgs.size), Toast.LENGTH_LONG).show()
callback.exitFlow()
}
private fun dismissWithSelection(selectedContacts: Set<ContactSearchKey>) {
Log.d(TAG, "dismissWithSelection")
callback.onFinishForwardAction()
dismissibleDialog?.dismiss()
val resultsBundle = Bundle().apply {
putParcelableArrayList(RESULT_SELECTION, ArrayList(selectedContacts.map { it.requireRecipientSearchKey() }))
}
callback.setResult(resultsBundle)
callback.exitFlow()
}
override fun sendAnywayAfterSafetyNumberChangedInBottomSheet(destinations: List<ContactSearchKey.RecipientSearchKey>) {
viewModel.confirmSafetySend(addMessage.text.toString(), destinations.toSet())
}
override fun onMessageResentAfterSafetyNumberChangeInBottomSheet() {
throw UnsupportedOperationException()
}
override fun onCanceled() {
viewModel.cancelSend()
}
private fun getStorySendRequirements(): Stories.MediaTransform.SendRequirements {
return requireListener<Callback>().getStorySendRequirements() ?: viewModel.snapshot.storySendRequirements
}
private fun filterContacts(view: View?, contactSet: Set<ContactSearchKey>): Set<ContactSearchKey> {
val storySendRequirements = getStorySendRequirements()
val resultsSet = contactSet.filterNot {
it is ContactSearchKey.RecipientSearchKey && it.isStory && storySendRequirements == Stories.MediaTransform.SendRequirements.CAN_NOT_SEND
}
if (view != null && contactSet.any { it is ContactSearchKey.RecipientSearchKey && it.isStory }) {
when (storySendRequirements) {
Stories.MediaTransform.SendRequirements.REQUIRES_CLIP -> {
displayTooltip(view, R.string.MultiselectForwardFragment__videos_will_be_trimmed)
}
Stories.MediaTransform.SendRequirements.CAN_NOT_SEND -> {
displayTooltip(view, R.string.MultiselectForwardFragment__videos_sent_to_stories_cant)
}
Stories.MediaTransform.SendRequirements.VALID_DURATION -> Unit
}
}
return resultsSet.toSet()
}
private fun displayTooltip(anchor: View, @StringRes text: Int) {
// 22dp + gutter
TooltipPopup
.forTarget(anchor)
.setText(text)
.show(TooltipPopup.POSITION_BELOW)
}
private fun getConfiguration(contactSearchState: ContactSearchState): ContactSearchConfiguration {
return findListener<SearchConfigurationProvider>()?.getSearchConfiguration(childFragmentManager, contactSearchState) ?: ContactSearchConfiguration.build {
query = contactSearchState.query
if (Stories.isFeatureEnabled() && isSelectedMediaValidForStories()) {
val expandedConfig: ContactSearchConfiguration.ExpandConfig? = if (isSelectedMediaValidForNonStories()) {
ContactSearchConfiguration.ExpandConfig(
isExpanded = contactSearchState.expandedSections.contains(ContactSearchConfiguration.SectionKey.STORIES),
maxCountWhenNotExpanded = {
if (args.isWrappedInBottomSheet) 1 else it + 1
}
)
} else {
null
}
addSection(
ContactSearchConfiguration.Section.Stories(
groupStories = contactSearchState.groupStories,
includeHeader = true,
headerAction = getHeaderAction(childFragmentManager),
expandConfig = expandedConfig
)
)
}
if (isSelectedMediaValidForNonStories()) {
if (query.isNullOrEmpty()) {
addSection(
ContactSearchConfiguration.Section.Recents(
includeHeader = true,
includeSelf = true
)
)
}
addSection(
ContactSearchConfiguration.Section.Individuals(
includeHeader = true,
transportType = if (includeSms()) ContactSearchConfiguration.TransportType.ALL else ContactSearchConfiguration.TransportType.PUSH,
includeSelf = true
)
)
addSection(
ContactSearchConfiguration.Section.Groups(
includeHeader = true,
includeMms = includeSms()
)
)
}
}
}
private fun includeSms(): Boolean {
return Util.isDefaultSmsProvider(requireContext()) && args.canSendToNonPush
}
private fun isSelectedMediaValidForStories(): Boolean {
return !args.isViewOnce && args.multiShareArgs.all { it.isValidForStories }
}
private fun isSelectedMediaValidForNonStories(): Boolean {
return args.multiShareArgs.all { it.isValidForNonStories }
}
override fun onGroupStoryClicked() {
if (SignalStore.storyValues().userHasSeenGroupStoryEducationSheet) {
onGroupStoryEducationSheetNext()
} else {
GroupStoryEducationSheet().show(childFragmentManager, GroupStoryEducationSheet.KEY)
}
}
override fun onNewStoryClicked() {
CreateStoryFlowDialogFragment().show(parentFragmentManager, CreateStoryWithViewersFragment.REQUEST_KEY)
}
override fun onGroupStoryEducationSheetNext() {
ChooseGroupStoryBottomSheet().show(parentFragmentManager, ChooseGroupStoryBottomSheet.GROUP_STORY)
}
override fun onWrapperDialogFragmentDismissed() {
contactSearchMediator.refresh()
}
override fun onMyStoryConfigured(recipientId: RecipientId) {
contactSearchMediator.setKeysSelected(setOf(ContactSearchKey.RecipientSearchKey(recipientId, true)))
contactSearchMediator.refresh()
}
interface Callback {
fun onFinishForwardAction()
fun exitFlow()
fun onSearchInputFocused()
fun setResult(bundle: Bundle)
fun getContainer(): ViewGroup
fun getDialogBackgroundColor(): Int
fun getStorySendRequirements(): Stories.MediaTransform.SendRequirements? = null
}
companion object {
private val TAG = Log.tag(MultiselectForwardActivity::class.java)
const val DIALOG_TITLE = "title"
const val ARGS = "args"
const val RESULT_KEY = "result_key"
const val RESULT_SELECTION = "result_selection_recipients"
const val RESULT_SENT = "result_sent"
@JvmStatic
fun showBottomSheet(supportFragmentManager: FragmentManager, multiselectForwardFragmentArgs: MultiselectForwardFragmentArgs) {
val fragment = MultiselectForwardBottomSheet()
showDialogFragment(supportFragmentManager, fragment, multiselectForwardFragmentArgs.copy(isWrappedInBottomSheet = true))
}
@JvmStatic
fun showFullScreen(supportFragmentManager: FragmentManager, multiselectForwardFragmentArgs: MultiselectForwardFragmentArgs) {
val fragment = MultiselectForwardFullScreenDialogFragment()
showDialogFragment(supportFragmentManager, fragment, multiselectForwardFragmentArgs)
}
@JvmStatic
fun create(multiselectForwardFragmentArgs: MultiselectForwardFragmentArgs): Fragment {
return MultiselectForwardFragment().apply {
arguments = bundleOf(ARGS to multiselectForwardFragmentArgs)
}
}
private fun showDialogFragment(supportFragmentManager: FragmentManager, fragment: DialogFragment, multiselectForwardFragmentArgs: MultiselectForwardFragmentArgs) {
fragment.arguments = bundleOf(ARGS to multiselectForwardFragmentArgs, DIALOG_TITLE to multiselectForwardFragmentArgs.title)
fragment.show(supportFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
}