Skip to content
Snippets Groups Projects
ConversationAdapter.kt 88.7 KiB
Newer Older
 *  Copyright (C) 2004-2023 Savoir-faire Linux Inc.
Adrien Béraud's avatar
Adrien Béraud committed
 *  This program is free software: you can redistribute it and/or modify
 *  it under the terms of the GNU General Public License as published by
Adrien Béraud's avatar
Adrien Béraud committed
 *  the Free Software Foundation, either version 3 of the License, or
 *  (at your option) any later version.
 *
 *  This program is distributed in the hope that it will be useful,
 *  but WITHOUT ANY WARRANTY; without even the implied warranty of
Adrien Béraud's avatar
Adrien Béraud committed
 *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 *  GNU General Public License for more details.
 *
 *  You should have received a copy of the GNU General Public License
Adrien Béraud's avatar
Adrien Béraud committed
 *  along with this program. If not, see <https://www.gnu.org/licenses/>.
 */
package cx.ring.adapters

import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ValueAnimator
import android.annotation.SuppressLint
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.graphics.SurfaceTexture
import android.graphics.drawable.Drawable
import android.graphics.drawable.GradientDrawable
import android.media.MediaPlayer
Adrien Béraud's avatar
Adrien Béraud committed
import android.net.Uri
import android.text.format.DateUtils
import android.text.format.Formatter
import android.util.TypedValue
import android.view.*
import android.view.ContextMenu.ContextMenuInfo
import android.view.TextureView.SurfaceTextureListener
import android.view.ViewGroup.MarginLayoutParams
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.PopupWindow
import android.widget.TextView
Adrien Béraud's avatar
Adrien Béraud committed
import androidx.annotation.ColorInt
import androidx.cardview.widget.CardView
import androidx.core.app.ActivityOptionsCompat
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.view.children
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.recyclerview.widget.RecyclerView
import androidx.vectordrawable.graphics.drawable.Animatable2Compat
import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat
import com.bumptech.glide.Glide
Adrien Béraud's avatar
Adrien Béraud committed
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions.withCrossFade
import com.google.android.material.color.MaterialColors
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import cx.ring.R
import cx.ring.client.MediaViewerActivity
import cx.ring.client.MessageEditActivity
import cx.ring.databinding.MenuConversationBinding
import cx.ring.fragments.ConversationFragment
Adrien Béraud's avatar
Adrien Béraud committed
import cx.ring.linkpreview.LinkPreview
import cx.ring.linkpreview.PreviewData
Adrien Béraud's avatar
Adrien Béraud committed
import cx.ring.utils.*
import cx.ring.utils.ActionHelper.Padding
import cx.ring.utils.ActionHelper.setPadding
import cx.ring.utils.ContentUriHandler.getUriForFile
Adrien Béraud's avatar
Adrien Béraud committed
import cx.ring.viewholders.ConversationViewHolder
import cx.ring.views.AvatarDrawable
import io.noties.markwon.AbstractMarkwonPlugin
import io.noties.markwon.Markwon
import io.noties.markwon.MarkwonVisitor
import io.noties.markwon.linkify.LinkifyPlugin
Adrien Béraud's avatar
Adrien Béraud committed
import io.reactivex.rxjava3.core.Maybe
import io.reactivex.rxjava3.core.Observable
import net.jami.conversation.ConversationPresenter
import net.jami.model.*
import net.jami.model.Account.ComposingStatus
import net.jami.model.Interaction.InteractionStatus
import net.jami.utils.Log
import net.jami.utils.StringUtils
import org.commonmark.node.SoftLineBreak
import java.io.File
import java.util.*
import java.util.concurrent.TimeUnit
import kotlin.math.max

class ConversationAdapter(
    private val conversationFragment: ConversationFragment,
    private val presenter: ConversationPresenter,
    private val isSearch: Boolean = false
) : RecyclerView.Adapter<ConversationViewHolder>() {
    private val mInteractions = ArrayList<Interaction>()
Adrien Béraud's avatar
Adrien Béraud committed
    private val res = conversationFragment.resources
    private val mPictureMaxSize =
        TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 200f, res.displayMetrics).toInt()
    private var mCurrentLongItem: RecyclerViewContextMenuInfo? = null
    @ColorInt
    private var convColor = 0
    @ColorInt
    private var convColorTint = 0
    private val formatter = Formatter(StringBuilder(64), Locale.getDefault())

    private val callPadding = Padding(
        res.getDimensionPixelSize(R.dimen.text_message_padding),
        res.getDimensionPixelSize(R.dimen.padding_call_vertical),
        res.getDimensionPixelSize(R.dimen.text_message_padding),
        res.getDimensionPixelSize(R.dimen.padding_call_vertical)
    )

    private var lastDeliveredPosition = -1
    private val timestampUpdateTimer: Observable<Long> =
        Observable.interval(10, TimeUnit.SECONDS, DeviceUtils.uiScheduler)
            .startWithItem(0L)
    private var lastMsgPos = -1
    private var isComposing = false
Adrien Béraud's avatar
Adrien Béraud committed
    var showLinkPreviews = true
    private val markwon: Markwon =
        Markwon.builder(conversationFragment.requireContext())
            .usePlugin(LinkifyPlugin.create())
            // Plugin to add a new line when a soft break is used.
            .usePlugin(object : AbstractMarkwonPlugin() {
                override fun configureVisitor(builder: MarkwonVisitor.Builder) {
                    builder.on(SoftLineBreak::class.java) { visitor, _ -> visitor.forceNewLine() }
                }
            })
            .build()

    /**
     * Refreshes the data and notifies the changes
     *
     * @param list an arraylist of interactions
     */
    @SuppressLint("NotifyDataSetChanged")
    fun updateDataset(list: List<Interaction>) {
        Log.d(TAG, "updateDataset: list size=" + list.size)
        when {
            mInteractions.isEmpty() -> {
                mInteractions.addAll(list)
                notifyDataSetChanged()
            }
            list.size > mInteractions.size -> {
                val oldSize = mInteractions.size
                mInteractions.addAll(list.subList(oldSize, list.size))
                notifyItemRangeInserted(oldSize, list.size)
            }
            else -> {
                mInteractions.clear()
                mInteractions.addAll(list)
                notifyDataSetChanged()
            }
        }
    }

    fun add(e: Interaction): Boolean {
            if (mInteractions.isEmpty() || mInteractions[mInteractions.size - 1].messageId == e.parentId) {
                val update = mInteractions.isNotEmpty()
                mInteractions.add(e)
                val previousLast = mInteractions.size - 1
                notifyItemInserted(previousLast)
                if (update) {
                    // Find previous last not invalid.
                    getPreviousInteractionFromPosition(previousLast)?.let { interactionNotInvalid ->
                        notifyItemChanged(mInteractions.lastIndexOf(interactionNotInvalid))
                    }
                }
                return true
            }
            var i = 0
            val n = mInteractions.size
            while (i < n) {
                if (e.messageId == mInteractions[i].parentId) {
                    Log.w(TAG, "Adding message at $i previous count $n")
                    mInteractions.add(i, e)
                    notifyItemInserted(i)
                    return i == n - 1
                } else if (e.parentId == mInteractions[i].messageId) {
                    mInteractions.add(i + 1, e)
                    notifyItemInserted(i + 1)
                    return true
                }
                i++
            }
        } else {
            val update = mInteractions.isNotEmpty()
            mInteractions.add(e)
            notifyItemInserted(mInteractions.size - 1)
            if (update) notifyItemChanged(mInteractions.size - 2)
        }
        return true
    }

    fun update(editedInteraction: Interaction) {
        if (!editedInteraction.isIncoming && editedInteraction.status == InteractionStatus.SUCCESS)
            notifyItemChanged(lastDeliveredPosition)

        mInteractions.indexOfLast { it.messageId == editedInteraction.messageId }.let {
            if (it == -1) return
            mInteractions[it] = editedInteraction
            notifyItemChanged(it)
        }
    }

    fun remove(e: Interaction) {
            for (i in mInteractions.indices.reversed()) {
                if (e.messageId == mInteractions[i].messageId) {
                    mInteractions.removeAt(i)
                    notifyItemRemoved(i)
                    if (i > 0) {
                        notifyItemChanged(i - 1)
                    }
                    if (i != mInteractions.size) {
                        notifyItemChanged(i)
                    }
                    break
                }
            }
        } else {
            for (i in mInteractions.indices.reversed()) {
                if (e.id == mInteractions[i].id) {
                    mInteractions.removeAt(i)
                    notifyItemRemoved(i)
                    if (i > 0) {
                        notifyItemChanged(i - 1)
                    }
                    if (i != mInteractions.size) {
                        notifyItemChanged(i)
                    }
                    break
                }
            }
        }
    }

    fun addSearchResults(interactions: List<Interaction>) {
        val oldSize = mInteractions.size
        mInteractions.addAll(interactions)
        notifyItemRangeInserted(oldSize, interactions.size)
    }

    fun clearSearchResults() {
        mInteractions.clear()
        notifyDataSetChanged()
    }

    /**
     * Updates the contact photo to use for this conversation
     */
    fun setPhoto() {
        notifyDataSetChanged()
Adrien Béraud's avatar
Adrien Béraud committed
    override fun getItemCount(): Int = mInteractions.size + if (isComposing) 1 else 0
Adrien Béraud's avatar
Adrien Béraud committed
    override fun getItemId(position: Int): Long =
        if (isComposing && position == mInteractions.size) Long.MAX_VALUE else mInteractions[position].id.toLong()
    fun getMessagePosition(messageId: String): Int {
        return mInteractions.indexOfFirst { it.messageId == messageId }
    }

    override fun getItemViewType(position: Int): Int {
        // This function will be called for each item in the list, to know which layout to use.

        // Composing indicator
        if (isComposing && position == mInteractions.size)
            return MessageType.COMPOSING_INDICATION.ordinal

        val interaction = mInteractions[position] // Get the interaction
        return when (interaction.type) {
            Interaction.InteractionType.CONTACT -> MessageType.CONTACT_EVENT.ordinal
            Interaction.InteractionType.CALL ->
                if ((interaction as Call).isGroupCall) {
                    MessageType.ONGOING_GROUP_CALL.ordinal
                } else if (interaction.isIncoming) {
                    MessageType.INCOMING_CALL_INFORMATION.ordinal
                } else MessageType.OUTGOING_CALL_INFORMATION.ordinal
            Interaction.InteractionType.TEXT ->
                if (interaction.isIncoming) {
                    MessageType.INCOMING_TEXT_MESSAGE.ordinal
                } else {
                    MessageType.OUTGOING_TEXT_MESSAGE.ordinal
                }
            Interaction.InteractionType.DATA_TRANSFER -> {
                val file = interaction as DataTransfer
                val out = if (interaction.isIncoming) 0 else 4
                if (file.isComplete) {
                    when {
                        file.isPicture -> return MessageType.INCOMING_IMAGE.ordinal + out
                        file.isAudio -> return MessageType.INCOMING_AUDIO.ordinal + out
                        file.isVideo -> return MessageType.INCOMING_VIDEO.ordinal + out
                    }
                }
                out
            }
            Interaction.InteractionType.INVALID -> MessageType.INVALID.ordinal
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ConversationViewHolder {
        val type = MessageType.values()[viewType]
        val v = if (type == MessageType.INVALID) FrameLayout(parent.context)
        else (LayoutInflater.from(parent.context).inflate(type.layout, parent, false) as ViewGroup)
        return ConversationViewHolder(v, type)
    }

    private fun configureDisplayIndicator(
        conversationViewHolder: ConversationViewHolder,
        interaction: Interaction
    ) {
        val conversation = interaction.conversation
        if (conversation == null || conversation !is Conversation) {
            conversationViewHolder.mStatusIcon?.isVisible = false
            return
        }
        conversationViewHolder.compositeDisposable.add(presenter.conversationFacade
            .getLoadedContact(
                interaction.account!!,
                conversation,
                interaction.displayedContacts
            )
            .observeOn(DeviceUtils.uiScheduler)
            .subscribe { contacts ->
                conversationViewHolder.mStatusIcon?.isVisible = contacts.isNotEmpty()
                conversationViewHolder.mStatusIcon?.update(
                    contacts,
                    interaction.status,
                    conversationViewHolder.mLayoutStatusIconId?.id ?: View.NO_ID
    /**
     * Configure a reaction chip and logic about it.
     *
     * @param conversationViewHolder the view layout.
     * @param interaction which have reactions
     */
    private fun configureReactions(
        conversationViewHolder: ConversationViewHolder,
        interaction: Interaction
    ) {
Pierre Nicolas's avatar
Pierre Nicolas committed
        val context = conversationViewHolder.itemView.context
        // If reaction chip is clicked we wants to display the reaction visualiser.
        conversationViewHolder.reactionChip?.setOnClickListener {
            val adapter = ReactionVisualizerAdapter(
Pierre Nicolas's avatar
Pierre Nicolas committed
                context,
                presenter::removeReaction
            )
Pierre Nicolas's avatar
Pierre Nicolas committed
            val dialog = MaterialAlertDialogBuilder(context)
                .setAdapter(adapter) { _, _ -> }
                .show()

            val observable = interaction.reactionObservable
                .map { reactions -> reactions.groupBy { reaction -> reaction.contact!! } }
                .switchMap { contactReactions ->
                    if (contactReactions.isEmpty())
                        return@switchMap Observable.just(emptyList())
                    // Iterate on entries.
                    val entry = contactReactions.entries.map { contactReactionsEntry ->
                        // Launch observeContact and transform the result
                        presenter.contactService.observeContact(
                            interaction.account!!, contactReactionsEntry.key, false
                        ).map { contact -> Pair(contact, contactReactionsEntry.value) }
                    }
                    // Subscribe on result.
                    Observable.combineLatest(entry) { resultDictionary ->
                        resultDictionary.map {
                            it as Pair<ContactViewModel, List<Interaction>>
                        }
                    }
                }
                .observeOn(DeviceUtils.uiScheduler)
                .subscribe { reactions ->
                    // Close dialog if no reactions anymore.
                    if (reactions.isEmpty()) {
                        dialog.dismiss()
                        return@subscribe
                    }
                    // Put the account at head of the list.
                    val mutableList = reactions.toMutableList()
                    val indexOfUserItem = mutableList.indexOfFirst { it.first.contact.isUser }
                    if (indexOfUserItem != -1) {
Pierre Nicolas's avatar
Pierre Nicolas committed
                        mutableList.add(0, mutableList.removeAt(indexOfUserItem))
                    }
                    adapter.setValues(mutableList)
                }
            conversationViewHolder.compositeDisposable.add(observable)

            dialog.setOnDismissListener {
                conversationViewHolder.compositeDisposable.remove(observable)
            }
        // Manage the display of the chip (ui element showing the emojis)
        conversationViewHolder.compositeDisposable.add(
            interaction.reactionObservable
                .observeOn(DeviceUtils.uiScheduler)
                .subscribe { reactions ->
                    conversationViewHolder.reactionChip?.let { chip ->
                        // No reaction, hide the chip.
                        if (reactions.isEmpty())
                            chip.isVisible = false
                        else {
                            chip.text = reactions.filterIsInstance<TextMessage>()
                                .groupingBy { it.body!! }
                                .eachCount()
                                .map { it }
                                .sortedByDescending { it.value }
                                .joinToString("") {
                                    if (it.value > 1) it.key + it.value else it.key
                                }

                            (chip.background as GradientDrawable).apply {
                                mutate()
                                setStroke(
                                    context.resources
                                        .getDimensionPixelSize(R.dimen.message_reaction_stroke),
                                    if (!interaction.isIncoming) convColor
                                    else context.getColor(R.color.border_color)
                                )
                            }

                            chip.isVisible = true
                            chip.isClickable = true
                            chip.isFocusable = true
                            //chip.isChecked = false
    /**
     * Configure a reply Interaction.
     *
     * @param conversationViewHolder    the view layout.
     * @param interaction               the interaction (contains the message data).
     */
    private fun configureReplyIndicator(
        conversationViewHolder: ConversationViewHolder,
        interaction: Interaction
    ) {
Pierre Nicolas's avatar
Pierre Nicolas committed
        val context = conversationViewHolder.itemView.context
        val conversation = interaction.conversation
        if (conversation == null || conversation !is Conversation) {
            conversationViewHolder.mReplyName?.isVisible = false
            conversationViewHolder.mReplyTxt?.isVisible = false
        conversationViewHolder.mReplyBubble?.let { replyBubble ->
            val replyTo = interaction.replyTo
            if (replyTo != null) {
                conversationViewHolder.compositeDisposable.add(replyTo
                    .flatMapObservable { reply ->
                        presenter.contactService
                            .observeContact(interaction.account!!, reply.contact!!, false)
                            .map { contact -> Pair(reply, contact) }
                    }
                    .observeOn(DeviceUtils.uiScheduler)
                        conversationViewHolder.mReplyTxt!!.text = i.first.body

                        // Name of whom we are replying to.
                        conversationViewHolder.mReplyName?.text =
                            if (i.first.isIncoming) i.second.displayName
                            else res.getString(R.string.conversation_reply_you)

                        // Apply the correct color depending if message is incoming or not.
                        val textColor:Int
                        if (i.first.isIncoming){
                            textColor = context.getColor(R.color.colorOnSurface)
                            replyBubble.background.setTint(
                                context.getColor(R.color.conversation_secondary_background)
                        }
                        else {
                            textColor = context.getColor(R.color.text_color_primary_dark)
                            replyBubble.background.setTint(convColor)
                        }
                        conversationViewHolder.mReplyTxt?.setTextColor(textColor)
                        conversationViewHolder.mReplyName?.setTextColor(textColor)
Pierre Nicolas's avatar
Pierre Nicolas committed
                        val avatarSize = context.resources
                            .getDimensionPixelSize(R.dimen.conversation_avatar_size_small)
                        val smallAvatarDrawable = AvatarDrawable.Builder()
                            .withContact(i.second)
                            .withCircleCrop(true)
Pierre Nicolas's avatar
Pierre Nicolas committed
                            .build(context)
                            .setInSize(avatarSize)
                        conversationViewHolder.mReplyName!!.setCompoundDrawablesWithIntrinsicBounds(
Pierre Nicolas's avatar
Pierre Nicolas committed
                            smallAvatarDrawable, null, null, null
                        replyBubble.isVisible = true
                        conversationViewHolder.mReplyTxt!!.isVisible = true

                        // User can click on mReplyTxt (replied message)
                        // or mReplyName (text above the message) to go to it.
                        listOf(conversationViewHolder.mReplyTxt,
                            replyBubble).forEach{
                            it?.setOnClickListener{
                                i.first.messageId?.let { presenter.scrollToMessage(it) }
                        replyBubble.isVisible = false
                        conversationViewHolder.mReplyTxt!!.isVisible = false
                    })
            } else { // Not replying to another message, we can hide reply Textview.
                replyBubble.isVisible = false
                conversationViewHolder.mReplyTxt?.isVisible = false
    override fun onBindViewHolder(conversationViewHolder: ConversationViewHolder, position: Int) {
        if (isComposing && position == mInteractions.size) {
            configureForTypingIndicator(conversationViewHolder)
            return
        }
        val interaction = mInteractions[position]
        conversationViewHolder.compositeDisposable.clear()
        /*if (position > lastMsgPos) {
            lastMsgPos = position
            val animation = AnimationUtils.loadAnimation(conversationViewHolder.itemView.context, R.anim.fade_in)
            animation.startOffset = 150
            conversationViewHolder.itemView.startAnimation(animation)
        conversationViewHolder.mStatusIcon?.let {
            configureDisplayIndicator(
                conversationViewHolder,
                interaction
            )
        }
        conversationViewHolder.mReplyName?.let {
            configureReplyIndicator(
                conversationViewHolder,
                interaction
            )
        }
        conversationViewHolder.reactionChip?.let {
            configureReactions(
                conversationViewHolder,
                interaction
            )
        }
Adrien Béraud's avatar
Adrien Béraud committed
        val type = interaction.type
        //Log.w(TAG, "onBindViewHolder $type $interaction");
Adrien Béraud's avatar
Adrien Béraud committed
        if (type == Interaction.InteractionType.INVALID) {
            conversationViewHolder.itemView.visibility = View.GONE
        } else {
            conversationViewHolder.itemView.visibility = View.VISIBLE
Adrien Béraud's avatar
Adrien Béraud committed
            when (type) {
                Interaction.InteractionType.TEXT -> configureForTextMessage(
                    conversationViewHolder,
                    interaction,
                    position
                )
                Interaction.InteractionType.CALL -> configureForCallInfo(
                    conversationViewHolder,
                    interaction,
                    position
                )
                Interaction.InteractionType.CONTACT -> configureForContactEvent(
                    conversationViewHolder,
                )
                Interaction.InteractionType.DATA_TRANSFER -> configureForFileInfo(
                    conversationViewHolder,
                    interaction,
                    position
                )

Adrien Béraud's avatar
Adrien Béraud committed
                else -> {}
            configureSearchResult(conversationViewHolder, interaction)
    }

    override fun onViewRecycled(holder: ConversationViewHolder) {
        holder.itemView.setOnLongClickListener(null)
        if (holder.mImage != null) {
            holder.mImage.setOnLongClickListener(null)
        }
        if (holder.video != null) {
            holder.video.setOnClickListener(null)
            holder.video.surfaceTextureListener = null
        }
Adrien Béraud's avatar
Adrien Béraud committed
        holder.surface?.release()
        holder.surface = null
        holder.player?.let { player ->
Adrien Béraud's avatar
Adrien Béraud committed
                if (player.isPlaying) player.stop()
                player.reset()
            } catch (e: Exception) {
                // left blank intentionally
            }
Adrien Béraud's avatar
Adrien Béraud committed
            player.release()
            holder.player = null
        }
Adrien Béraud's avatar
Adrien Béraud committed
        holder.mMsgTxt?.setOnLongClickListener(null)
        holder.mItem?.setOnClickListener(null)
        holder.compositeDisposable.clear()
    }

Adrien Béraud's avatar
Adrien Béraud committed
    fun setPrimaryColor(@ColorInt color: Int) {
        convColor = color
        convColorTint =
            MaterialColors.compositeARGBWithAlpha(color, (MaterialColors.ALPHA_LOW * 255).toInt())
        notifyDataSetChanged()
    }

    fun getPrimaryColor() = convColor

    fun setComposingStatus(composingStatus: ComposingStatus) {
        val composing = composingStatus == ComposingStatus.Active
        if (isComposing != composing) {
            isComposing = composing
            if (composing) notifyItemInserted(mInteractions.size) else notifyItemRemoved(
                mInteractions.size
            )
        }
    }

    private class RecyclerViewContextMenuInfo(
        val position: Int,
        val id: Long
    ) : ContextMenuInfo

    private fun addToClipboard(text: String?) {
        if (text.isNullOrEmpty()) return
        val clipboard = conversationFragment.requireActivity()
            .getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
        clipboard.setPrimaryClip(ClipData.newPlainText("Copied Message", text))
    private fun configureImage(
        viewHolder: ConversationViewHolder,
        path: File,
        displayName: String?
    ) {
Adrien Béraud's avatar
Adrien Béraud committed
        val context = viewHolder.itemView.context
Adrien Béraud's avatar
Adrien Béraud committed
        val image = viewHolder.mImage ?: return
        image.clipToOutline = true
        Glide.with(context)
Adrien Béraud's avatar
Adrien Béraud committed
            .transition(withCrossFade())
            .into(image)
        image.setOnClickListener { v: View ->
                val contentUri =
                    getUriForFile(v.context, ContentUriHandler.AUTHORITY_FILES, path, displayName)
                val i = Intent(context, MediaViewerActivity::class.java)
                    .setAction(Intent.ACTION_VIEW)
                    .setDataAndType(contentUri, "image/*")
                    .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
                val options = ActivityOptionsCompat.makeSceneTransitionAnimation(
                    conversationFragment.requireActivity(),
                    viewHolder.mImage,
                    "picture"
                )
                conversationFragment.startActivityForResult(i, 3006, options.toBundle())
            } catch (e: Exception) {
                Log.w(TAG, "Can't open picture", e)
            }
        }
    }

    private fun configureAudio(viewHolder: ConversationViewHolder, path: File) {
        val context = viewHolder.itemView.context
        try {
Adrien Béraud's avatar
Adrien Béraud committed
            val acceptBtn = viewHolder.btnAccept as ImageView
            val refuseBtn = viewHolder.btnRefuse!!
            acceptBtn.setImageResource(R.drawable.baseline_play_arrow_24)
            val player = MediaPlayer.create(
                context,
                getUriForFile(context, ContentUriHandler.AUTHORITY_FILES, path)
            )
            viewHolder.player = player
            if (player != null) {
                player.setOnCompletionListener { mp: MediaPlayer ->
                    mp.seekTo(0)
Adrien Béraud's avatar
Adrien Béraud committed
                    acceptBtn.setImageResource(R.drawable.baseline_play_arrow_24)
Adrien Béraud's avatar
Adrien Béraud committed
                acceptBtn.setOnClickListener {
                    if (player.isPlaying) {
                        player.pause()
Adrien Béraud's avatar
Adrien Béraud committed
                        acceptBtn.setImageResource(R.drawable.baseline_play_arrow_24)
                    } else {
                        player.start()
Adrien Béraud's avatar
Adrien Béraud committed
                        acceptBtn.setImageResource(R.drawable.baseline_pause_24)
Adrien Béraud's avatar
Adrien Béraud committed
                refuseBtn.setOnClickListener {
                    if (player.isPlaying) player.pause()
                    player.seekTo(0)
Adrien Béraud's avatar
Adrien Béraud committed
                    acceptBtn.setImageResource(R.drawable.baseline_play_arrow_24)
                }
                viewHolder.compositeDisposable.add(
                    Observable.interval(1L, TimeUnit.SECONDS, DeviceUtils.uiScheduler)
                        .startWithItem(0L)
                            val pS = player.currentPosition / 1000
                            val dS = player.duration / 1000
                            viewHolder.mMsgTxt?.text = String.format(
                                Locale.getDefault(),
                                "%02d:%02d / %02d:%02d",
                                pS / 60,
                                pS % 60,
                                dS / 60,
                                dS % 60
                            )
Adrien Béraud's avatar
Adrien Béraud committed
                acceptBtn.setOnClickListener(null)
                refuseBtn.setOnClickListener(null)
            }
        } catch (e: Exception) {
            Log.e(TAG, "Error initializing player", e)
        }
    }

    private fun configureVideo(viewHolder: ConversationViewHolder, path: File) {
        val context = viewHolder.itemView.context
        viewHolder.player?.let {
            viewHolder.player = null
            it.release()
        }
Adrien Béraud's avatar
Adrien Béraud committed
        val video = viewHolder.video ?: return
        val cardLayout = viewHolder.mLayout as CardView

        val contentUri = try {
            getUriForFile(context, ContentUriHandler.AUTHORITY_FILES, path)
        } catch (e: Exception) {
            Log.w(TAG, "Can't open video", e)
            return
        }
        val player = MediaPlayer.create(context, contentUri) ?: return

        viewHolder.player = player
        val playBtn =
            ContextCompat.getDrawable(cardLayout.context, R.drawable.baseline_play_arrow_24)!!
                .mutate()
        DrawableCompat.setTint(playBtn, Color.WHITE)
Adrien Béraud's avatar
Adrien Béraud committed
        cardLayout.foreground = playBtn
        player.setOnCompletionListener { mp: MediaPlayer ->
            if (mp.isPlaying) mp.pause()
            mp.seekTo(1)
Adrien Béraud's avatar
Adrien Béraud committed
            cardLayout.foreground = playBtn
Pierre Nicolas's avatar
Pierre Nicolas committed
        player.setOnVideoSizeChangedListener { _: MediaPlayer, width: Int, height: Int ->
            Log.w(TAG, "OnVideoSizeChanged " + width + "x" + height)
Adrien Béraud's avatar
Adrien Béraud committed
            val p = video.layoutParams as FrameLayout.LayoutParams
            val maxDim = max(width, height)
            if (maxDim != 0) {
Adrien Béraud's avatar
Adrien Béraud committed
                p.width = width * mPictureMaxSize / maxDim
                p.height = height * mPictureMaxSize / maxDim
            } else {
                p.width = 0
                p.height = 0
            }
Adrien Béraud's avatar
Adrien Béraud committed
            video.layoutParams = p
Adrien Béraud's avatar
Adrien Béraud committed
        if (video.isAvailable) {
            if (viewHolder.surface == null) {
Adrien Béraud's avatar
Adrien Béraud committed
                viewHolder.surface = Surface(video.surfaceTexture)
            }
            player.setSurface(viewHolder.surface)
        }
Adrien Béraud's avatar
Adrien Béraud committed
        video.surfaceTextureListener = object : SurfaceTextureListener {
            override fun onSurfaceTextureAvailable(
                surfaceTexture: SurfaceTexture,
                width: Int,
                height: Int
            ) {
                if (viewHolder.surface == null) {
Adrien Béraud's avatar
Adrien Béraud committed
                    viewHolder.surface = Surface(surfaceTexture).also { surface ->
                        try {
                            player.setSurface(surface)
                        } catch (e: Exception) {
                            // Left blank
                        }
            override fun onSurfaceTextureSizeChanged(
                surface: SurfaceTexture,
                width: Int,
                height: Int
            ) {
            }

            override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean {
                try {
                    player.setSurface(null)
                } catch (e: Exception) {
                    // Left blank
                }
                viewHolder.surface?.let {
                    viewHolder.surface = null
                    it.release()
                }
                return true
            }

            override fun onSurfaceTextureUpdated(surface: SurfaceTexture) {}
        }
Adrien Béraud's avatar
Adrien Béraud committed
        video.setOnClickListener {
            try {
                if (player.isPlaying) {
                    player.pause()
                    (viewHolder.mLayout as CardView).foreground = playBtn
                } else {
                    player.start()
                    (viewHolder.mLayout as CardView).foreground = null
                }
            } catch (e: Exception) {
                // Left blank
            }
        }
        player.seekTo(1)
    }

    /**
     * Display and manage the popup that allows user to react/reply/share/edit/delete message ...
     *
     * @param conversationViewHolder the view layout.
     * @param view
     * @param interaction
     */
    private fun openItemMenu(
        conversationViewHolder: ConversationViewHolder, view: View, interaction: Interaction
    ) {

        // Inflate design from XML.
        MenuConversationBinding.inflate(LayoutInflater.from(view.context)).apply {
            val history = interaction.historyObservable.blockingFirst()
            val lastElement = history.last()
            val isDeleted = lastElement is TextMessage && lastElement.body.isNullOrEmpty()

            // Configure what should be displayed
            convActionOpenText.isVisible = interaction is DataTransfer && interaction.isComplete
            convActionDownloadText.isVisible = interaction is DataTransfer && interaction.isComplete
            convActionCopyText.isVisible = !isDeleted && interaction !is DataTransfer
            convActionEdit.isVisible =
                !isDeleted && !interaction.isIncoming && interaction !is DataTransfer
            convActionDelete.isVisible = !isDeleted && !interaction.isIncoming
            convActionHistory.isVisible = !isDeleted && history.size > 1
            root.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED)

            // The popup that display all the buttons
            val popupWindow = PopupWindow(
                root, LinearLayout.LayoutParams.WRAP_CONTENT, root.measuredHeight, true
            )
                .apply {
                    elevation = view.context.resources.getDimension(R.dimen.call_preview_elevation)
                    showAsDropDown(view)
                }

            val textViews = listOf(
                convActionEmoji1.chip, convActionEmoji2.chip,
                convActionEmoji3.chip, convActionEmoji4.chip
            )

            convActionEmoji1.chip.text = view.context.getString(R.string.default_emoji_1)
            convActionEmoji2.chip.text = view.context.getString(R.string.default_emoji_2)
            convActionEmoji3.chip.text = view.context.getString(R.string.default_emoji_3)
            convActionEmoji4.chip.text = view.context.getString(R.string.default_emoji_4)

            // Subscribe on reactions to allows user to see which reaction he already selected.
            val disposable = interaction.reactionObservable
                .observeOn(DeviceUtils.uiScheduler)
                .subscribe { reactions ->
                    textViews.forEach { textView ->
                        // Set checked reactions already sent.
                        textView.isChecked = reactions.any {
                            (textView.text == it.body) && (it.contact?.isUser == true)
                        }
                    popupWindow.update()
            conversationViewHolder.compositeDisposable.add(disposable)

            popupWindow.setOnDismissListener {
                val type = conversationViewHolder.type.transferType
                if (convColor != 0 && (interaction.type == Interaction.InteractionType.TEXT
                            || type == MessageType.TransferType.FILE
                            || type == MessageType.TransferType.AUDIO ) && !interaction.isIncoming
                ) view.background?.setTint(convColor)
                else view.background?.setTintList(null)
                // Remove disposable.
                conversationViewHolder.compositeDisposable.remove(disposable)
            }

            // Callback executed when emoji is clicked.
            // We want to know if the reaction is already set.
            // If set we want to remove, else we want to append.
            val emojiCallback = View.OnClickListener { view ->
                // Subscribe to know which are the current reactions.
                conversationViewHolder.compositeDisposable.add(interaction.reactionObservable
                    .observeOn(DeviceUtils.uiScheduler)
                    .firstOrError()
                    .subscribe { reactions ->
                        // Try to find a reaction having corresponding to the one clicked.
                        val reaction = reactions.firstOrNull {
                            (it.body == (view as TextView).text) && (it.contact?.isUser == true)
                        }
                        if (reaction != null)
                        // Previously, it was not forbidden to send multiple times the same
                        // reaction. Hence, we only remove the first one.
                            presenter.removeReaction(reaction)
                        else // If null, it means we didn't find anything. So let's send it.
                            presenter.sendReaction(interaction, (view as TextView).text)
                        popupWindow.dismiss()
                    }
                )
            textViews.forEach { it.setOnClickListener(emojiCallback) } // Set callback

            // Configure reply
            convActionReply.setOnClickListener {
                presenter.startReplyTo(interaction)
                popupWindow.dismiss()
            emojiPicker.setOnEmojiPickedListener {
                presenter.sendReaction(interaction, it.emoji)
                popupWindow.dismiss()
            }
            convActionMore.setOnClickListener {
                val newState = menuActions.isVisible
                menuActions.isVisible = !newState
                emojiPicker.isVisible = newState
                popupWindow.let {
                    root.measure(root.width, View.MeasureSpec.UNSPECIFIED)
                    it.height = root.measuredHeight
                    it.dismiss()
                    it.showAsDropDown(view)
                }
            convActionOpenText.setOnClickListener {
                presenter.openFile(interaction)
            }
            convActionDownloadText.setOnClickListener {
                presenter.saveFile(interaction)
            }
            convActionCopyText.setOnClickListener {
                addToClipboard(lastElement.body)
                popupWindow.dismiss()

            // Manage Edit and Delete actions
            if (!interaction.isIncoming) {
                convActionEdit.setOnClickListener {
                    try {
                        val i = Intent(it.context, MessageEditActivity::class.java)
                            .setData(
                                Uri.withAppendedPath(
                                    ConversationPath.toUri(
                                        interaction.account!!,
                                        interaction.conversationId!!
                                    ), interaction.messageId
                                )
                            )
                            .setAction(Intent.ACTION_EDIT)
                            .putExtra(
                                Intent.EXTRA_TEXT,
                                conversationViewHolder.mMessageContent!!.getText().toString()
                            )
                        val options = ActivityOptionsCompat.makeSceneTransitionAnimation(
                            conversationFragment.requireActivity(),
                            conversationViewHolder.mMessageBubble!!,
                            "messageEdit"
                        )
                        conversationFragment.startActivityForResult(
                            i,