Code owners
Assign users and groups as approvers for specific file changes. Learn more.
ConversationAdapter.kt 91.95 KiB
/*
* Copyright (C) 2004-2023 Savoir-faire Linux Inc.
*
* 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
* 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
* 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
* 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
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
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
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
import cx.ring.linkpreview.LinkPreview
import cx.ring.linkpreview.PreviewData
import cx.ring.utils.*
import cx.ring.utils.ActionHelper.Padding
import cx.ring.utils.ActionHelper.setPadding
import cx.ring.utils.ContentUriHandler.getUriForFile
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
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>()
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 emojiMessagePadding = Padding(0, 0, 0, 0)
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 val textMessagePadding = Padding(
res.getDimensionPixelSize(R.dimen.text_message_padding),
res.getDimensionPixelSize(R.dimen.text_message_padding),
res.getDimensionPixelSize(R.dimen.text_message_padding),
res.getDimensionPixelSize(R.dimen.text_message_padding)
)
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
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 (e.isSwarm) {
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(e: Interaction) {
Log.w(TAG, "update " + e.messageId)
if (!e.isIncoming && e.status == InteractionStatus.SUCCESS) {
notifyItemChanged(lastDeliveredPosition)
}
for (i in mInteractions.indices.reversed()) {
val element = mInteractions[i]
if (e === element) {
notifyItemChanged(i)
break
}
}
}
fun remove(e: Interaction) {
if (e.isSwarm) {
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()
}
override fun getItemCount(): Int = mInteractions.size + if (isComposing) 1 else 0
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
) {
val context = conversationViewHolder.itemView.context
// If reaction chip is clicked we wants to display the reaction visualiser.
conversationViewHolder.reactionChip?.setOnClickListener {
val adapter = ReactionVisualizerAdapter(
context,
presenter::removeReaction
)
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) {
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
) {
val context = conversationViewHolder.itemView.context
val conversation = interaction.conversation
if (conversation == null || conversation !is Conversation) {
conversationViewHolder.mReplyName?.isVisible = false
conversationViewHolder.mReplyTxt?.isVisible = false
return
}
conversationViewHolder.mReplyBubble?.let { replyBubble ->
val replyTo = interaction.replyTo
// If currently replying to another message :
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)
.subscribe({ i ->
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)
// Load avatar drawable from contact.
val avatarSize = context.resources
.getDimensionPixelSize(R.dimen.conversation_avatar_size_small)
val smallAvatarDrawable = AvatarDrawable.Builder()
.withContact(i.second)
.withCircleCrop(true)
.build(context)
.setInSize(avatarSize)
// Update the view.
conversationViewHolder.mReplyName!!.setCompoundDrawablesWithIntrinsicBounds(
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
)
}
val type = interaction.type
//Log.w(TAG, "onBindViewHolder $type $interaction");
if (type == Interaction.InteractionType.INVALID) {
conversationViewHolder.itemView.visibility = View.GONE
} else {
conversationViewHolder.itemView.visibility = View.VISIBLE
when (type) {
Interaction.InteractionType.TEXT -> configureForTextMessage(
conversationViewHolder,
interaction,
position
)
Interaction.InteractionType.CALL -> configureForCallInfo(
conversationViewHolder,
interaction,
position
)
Interaction.InteractionType.CONTACT -> configureForContactEvent(
conversationViewHolder,
interaction
)
Interaction.InteractionType.DATA_TRANSFER -> configureForFileInfo(
conversationViewHolder,
interaction,
position
)
else -> {}
}
}
if (isSearch)
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
}
holder.surface?.release()
holder.surface = null
holder.player?.let { player ->
try {
if (player.isPlaying) player.stop()
player.reset()
} catch (e: Exception) {
// left blank intentionally
}
player.release()
holder.player = null
}
holder.mMsgTxt?.setOnLongClickListener(null)
holder.mItem?.setOnClickListener(null)
holder.compositeDisposable.clear()
}
fun setPrimaryColor(@ColorInt color: Int) {
convColor = color
convColorTint =
MaterialColors.compositeARGBWithAlpha(color, (MaterialColors.ALPHA_LOW * 255).toInt())
notifyDataSetChanged()
}
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
fun onContextItemSelected(item: MenuItem): Boolean {
val info = mCurrentLongItem ?: return false
val interaction = try {
mInteractions[info.position]
} catch (e: IndexOutOfBoundsException) {
Log.e(TAG, "Interaction array may be empty or null", e)
return false
}
if (interaction.type == Interaction.InteractionType.CONTACT) return false
when (item.itemId) {
R.id.conv_action_download -> presenter.saveFile(interaction)
R.id.conv_action_share -> presenter.shareFile(interaction as DataTransfer)
R.id.conv_action_open -> presenter.openFile(interaction)
R.id.conv_action_delete -> presenter.deleteConversationItem(interaction)
R.id.conv_action_cancel_message -> presenter.cancelMessage(interaction)
R.id.conv_action_reply -> presenter.startReplyTo(interaction)
R.id.conv_action_copy_text -> addToClipboard(interaction.body)
}
return true
}
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?
) {
val context = viewHolder.itemView.context
val image = viewHolder.mImage ?: return
image.clipToOutline = true
Glide.with(context)
.load(path)
.transition(withCrossFade())
.into(image)
image.setOnClickListener { v: View ->
try {
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 {
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)
acceptBtn.setImageResource(R.drawable.baseline_play_arrow_24)
}
acceptBtn.setOnClickListener {
if (player.isPlaying) {
player.pause()
acceptBtn.setImageResource(R.drawable.baseline_play_arrow_24)
} else {
player.start()
acceptBtn.setImageResource(R.drawable.baseline_pause_24)
}
}
refuseBtn.setOnClickListener {
if (player.isPlaying) player.pause()
player.seekTo(0)
acceptBtn.setImageResource(R.drawable.baseline_play_arrow_24)
}
viewHolder.compositeDisposable.add(
Observable.interval(1L, TimeUnit.SECONDS, DeviceUtils.uiScheduler)
.startWithItem(0L)
.subscribe {
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
)
})
} else {
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()
}
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)
cardLayout.foreground = playBtn
player.setOnCompletionListener { mp: MediaPlayer ->
if (mp.isPlaying) mp.pause()
mp.seekTo(1)
cardLayout.foreground = playBtn
}
player.setOnVideoSizeChangedListener { _: MediaPlayer, width: Int, height: Int ->
Log.w(TAG, "OnVideoSizeChanged " + width + "x" + height)
val p = video.layoutParams as FrameLayout.LayoutParams
val maxDim = max(width, height)
if (maxDim != 0) {
p.width = width * mPictureMaxSize / maxDim
p.height = height * mPictureMaxSize / maxDim
} else {
p.width = 0
p.height = 0
}
video.layoutParams = p
}
if (video.isAvailable) {
if (viewHolder.surface == null) {
viewHolder.surface = Surface(video.surfaceTexture)
}
player.setSurface(viewHolder.surface)
}
video.surfaceTextureListener = object : SurfaceTextureListener {
override fun onSurfaceTextureAvailable(
surfaceTexture: SurfaceTexture,
width: Int,
height: Int
) {
if (viewHolder.surface == null) {
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) {}
}
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) && !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)
}
}
// Open file
convActionOpenText.setOnClickListener {
presenter.openFile(interaction)
}
// Save file
convActionDownloadText.setOnClickListener {
presenter.saveFile(interaction)
}
// Manage copy
convActionCopyText.setOnClickListener {
addToClipboard(lastElement.body)
popupWindow.dismiss()
}
// Manage Edit and Delete actions
if (!interaction.isIncoming) {
// Edit
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,
ConversationFragment.REQUEST_CODE_EDIT_MESSAGE,
options.toBundle()
)
} catch (e: Exception) {
Log.w(TAG, "Can't open picture", e)
}
popupWindow.dismiss()
}
// Delete
convActionDelete.setOnClickListener {
presenter.deleteConversationItem(interaction)
popupWindow.dismiss()
}
} else {
convActionEdit.setOnClickListener(null)
convActionDelete.setOnClickListener(null)
}
// Share
convActionShare.setOnClickListener {
if (interaction is DataTransfer)
presenter.shareFile(interaction)
else if (interaction is TextMessage)
presenter.shareText(interaction)
popupWindow.dismiss()
}
// Message history
if (convActionHistory.isVisible)
convActionHistory.setOnClickListener {
conversationViewHolder.compositeDisposable.add(
interaction.historyObservable.firstOrError().subscribe { c ->
Log.w(TAG, "Message history ${c.size}")
c.forEach {
Log.w(TAG, "Message ${it.type} ${it.body} $it")
}
MaterialAlertDialogBuilder(it.context)
.setTitle("Message history")
.setItems(c.filterIsInstance<TextMessage>().map { it.body!! }
.toTypedArray())
{ dialog, _ -> dialog.dismiss() }
.create()
.show()
}
)
popupWindow.dismiss()
}
}
}
@SuppressLint("RestrictedApi", "ClickableViewAccessibility")
private fun configureForFileInfo(
viewHolder: ConversationViewHolder,
interaction: Interaction,
position: Int
) {
val context = viewHolder.itemView.context
val file = interaction as DataTransfer
val path = presenter.deviceRuntimeService.getConversationPath(file)
val timeString = TextUtils.timestampToTime(context, formatter, file.timestamp)
viewHolder.mFileTime?.text = timeString
viewHolder.compositeDisposable.add(timestampUpdateTimer.subscribe {
viewHolder.mFileSize?.text = when (val status = file.status) {
InteractionStatus.TRANSFER_FINISHED -> String.format("%s - %s",
Formatter.formatFileSize(context, file.totalSize),
TextUtils.getReadableFileTransferStatus(context, status)
)
InteractionStatus.TRANSFER_ONGOING -> String.format("%s / %s - %s",
Formatter.formatFileSize(context, file.bytesProgress),
Formatter.formatFileSize(context, file.totalSize),
TextUtils.getReadableFileTransferStatus(context, status)
)
else -> String.format(
Formatter.formatFileSize(context, file.totalSize)
)
}
})
val isDateShown = hasPermanentDateString(file, position)
if (isDateShown) {
viewHolder.compositeDisposable.add(timestampUpdateTimer.subscribe {
viewHolder.mMsgDetailTxtPerm?.text =
TextUtils.timestampToDate(context, formatter, file.timestamp)
})
viewHolder.mMsgDetailTxtPerm?.visibility = View.VISIBLE
} else {
viewHolder.mMsgDetailTxtPerm?.visibility = View.GONE
}
val contact = interaction.contact ?: return
if (interaction.isIncoming && presenter.isGroup()) {
viewHolder.mAvatar?.let { avatar ->
avatar.setImageBitmap(null)
avatar.visibility = View.VISIBLE
avatar.setImageDrawable(
conversationFragment.getConversationAvatar(contact.primaryNumber)
)
}
val account = interaction.account?: return
// Show the name of the contact.
viewHolder.mPeerDisplayName?.apply {
visibility = View.VISIBLE
viewHolder.compositeDisposable.add(
presenter.contactService
.observeContact(account, contact, false)
.observeOn(DeviceUtils.uiScheduler)
.subscribe { text = it.displayName }
)
}
} else {
viewHolder.mAvatar?.visibility = View.GONE
viewHolder.mPeerDisplayName?.visibility = View.GONE
}
val type = viewHolder.type.transferType
val longPressView = when (type) {
MessageType.TransferType.IMAGE -> viewHolder.mImage
MessageType.TransferType.VIDEO -> viewHolder.video
MessageType.TransferType.AUDIO -> viewHolder.mAudioInfoLayout
else -> viewHolder.mFileInfoLayout
} ?: return
if (type == MessageType.TransferType.AUDIO || type == MessageType.TransferType.FILE) {
longPressView.background?.setTintList(null)
}
longPressView.setOnLongClickListener { v: View ->
if (type == MessageType.TransferType.AUDIO || type == MessageType.TransferType.FILE) {
conversationFragment.updatePosition(viewHolder.bindingAdapterPosition)
if (file.isIncoming) {
longPressView.background.setTint(context.getColor(R.color.grey_500))
} else {
longPressView.background.setTint(convColorTint)
}
}
openItemMenu(viewHolder, v, file)
mCurrentLongItem =
RecyclerViewContextMenuInfo(viewHolder.bindingAdapterPosition, v.id.toLong())
true
}
val isMessageSeparationNeeded = isMessageSeparationNeeded(isDateShown, position)
when (type) {
MessageType.TransferType.IMAGE -> { configureImage(viewHolder, path, file.body) }
MessageType.TransferType.VIDEO -> { configureVideo(viewHolder, path) }
MessageType.TransferType.AUDIO -> { configureAudio(viewHolder, path) }
else -> {
// Add margin if message need to be separated.
viewHolder.mLayout?.updateLayoutParams<MarginLayoutParams> {
topMargin = if (!isMessageSeparationNeeded) 0 else context.resources
.getDimensionPixelSize(R.dimen.conversation_message_separation)
}
val status = file.status
viewHolder.mIcon?.setImageResource(
if (status.isError) R.drawable.baseline_warning_24
else R.drawable.baseline_attach_file_24
)
viewHolder.mFileTitle?.text = file.displayName
viewHolder.mFileInfoLayout?.setOnClickListener(null)
// Set the tint of the file background
if (file.isOutgoing) viewHolder.mFileInfoLayout?.background?.setTint(convColor)
// Show the download button
when (status) {
InteractionStatus.TRANSFER_AWAITING_HOST, InteractionStatus.FILE_AVAILABLE -> {
viewHolder.mFileDownloadButton?.let {
it.visibility = View.VISIBLE
it.setOnClickListener { presenter.acceptFile(file) }
}
}
else -> {
viewHolder.mFileDownloadButton?.visibility = View.GONE
if (status == InteractionStatus.TRANSFER_ONGOING) {
viewHolder.progress?.max = (file.totalSize / 1024).toInt()
viewHolder.progress?.setProgress(
(file.bytesProgress / 1024).toInt(), true
)
viewHolder.progress?.show()
} else {
viewHolder.progress?.hide()
}
viewHolder.mFileInfoLayout?.setOnClickListener { presenter.openFile(file) }
}
}
}
}
}
private fun configureForTypingIndicator(viewHolder: ConversationViewHolder) {
// Set the alignment of the typing indicator.
val layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.WRAP_CONTENT,
FrameLayout.LayoutParams.WRAP_CONTENT
)
if (presenter.isGroup()) {
layoutParams.setMargins(
res.getDimensionPixelSize(R.dimen.margin_with_avatar), 0,
0, 0
)
} else {
layoutParams.setMargins(
res.getDimensionPixelSize(R.dimen.margin_without_avatar), 0,
0, 0
)
}
viewHolder.mTypingIndicatorLayout?.layoutParams = layoutParams
//Start the animation.
AnimatedVectorDrawableCompat.create(
viewHolder.itemView.context, R.drawable.typing_indicator_animation
)?.let { anim ->
viewHolder.mIcon?.setImageDrawable(anim)
anim.registerAnimationCallback(object : Animatable2Compat.AnimationCallback() {
override fun onAnimationEnd(drawable: Drawable) {
anim.start()
}
})
anim.start()
}
}
/**
* Configures the background of the message bubble.
* It changes if it's an incoming/outgoing message, it position or if it's an emoji.
* ResIndex indicates how is considered the message (first, single, last, etc.).
*/
private fun updateMessageBackground(
context: Context, messageBubble: View, messageBubbleBorder: View,
messageSequenceType: SequenceType,
isOnlyEmoji: Boolean, isReplying: Boolean, isDeleted: Boolean, isIncoming: Boolean,
) {
if (isOnlyEmoji) messageBubble.background = null
else {
// Manage layout for standard message. Index refers to msgBGLayouts array.
val resIndex =
if (isReplying && !isDeleted) {
// Reply message incoming, first or single.
if (isIncoming) if (messageSequenceType == SequenceType.FIRST) 11 else 10
// Reply message outgoing, first or single
else if (messageSequenceType == SequenceType.FIRST) 9 else 8
}
// Standard message, incoming or outgoing and first, single or last.
else messageSequenceType.ordinal + (if (isIncoming) 1 else 0) * 4
messageBubble.background = ContextCompat.getDrawable(context, msgBGLayouts[resIndex])
if (isReplying && !isDeleted) messageBubbleBorder.background =
ContextCompat.getDrawable(context, msgBGLayouts[resIndex])
if (convColor != 0 && !isIncoming) messageBubble.background?.setTint(convColor)
}
}
/**
* Configures the left margin of the message bubble.
* It changes if it's a group (avatar is displayed) or not (avatar is gone).
*/
private fun updateMessageLeftMargin(
context: Context,
messageBubbleBorder: View, replyBubble: View?,
isGroup: Boolean, isIncoming: Boolean
) {
if (isGroup && isIncoming) {
context.resources.getDimensionPixelSize(R.dimen.margin_no_group).let {
(messageBubbleBorder.layoutParams as MarginLayoutParams)
.apply { leftMargin = it }
replyBubble?.let { replyBubble ->
(replyBubble.layoutParams as MarginLayoutParams)
.apply { leftMargin = it }
}
}
} else {
context.resources.getDimensionPixelSize(R.dimen.margin_without_avatar).let {
(messageBubbleBorder.layoutParams as MarginLayoutParams)
.apply { leftMargin = it }
replyBubble?.let { replyBubble ->
(replyBubble.layoutParams as MarginLayoutParams)
.apply { leftMargin = it }
}
}
}
}
/**
* Configures the viewHolder to display a classic text message, ie. not a call info text message
*
* @param convViewHolder The conversation viewHolder
* @param interaction The conversation element to display
* @param position The position of the viewHolder
*/
private fun configureForTextMessage(
convViewHolder: ConversationViewHolder,
interaction: Interaction,
position: Int
) {
val context = convViewHolder.itemView.context
convViewHolder.compositeDisposable.add(interaction.lastElement
.observeOn(DeviceUtils.uiScheduler)
.subscribe { lastElement ->
val textMessage = lastElement as TextMessage
val account = interaction.account ?: return@subscribe
val contact = textMessage.contact ?: return@subscribe
val isDeleted = textMessage.body.isNullOrEmpty()
val isEdited = interaction.history.size > 1
val messageContent = convViewHolder.mMessageContent ?: return@subscribe
val messageBubble = convViewHolder.mMessageBubble ?: return@subscribe
val messageBubbleBorder = convViewHolder.mMessageBubbleBorder ?: return@subscribe
val replyBubble = convViewHolder.mReplyBubble
val answerLayout = convViewHolder.mAnswerLayout
val peerDisplayName = convViewHolder.mPeerDisplayName
val isDateShown = hasPermanentDateString(interaction, position)
val msgSequenceType = getMsgSequencing(position, isDateShown)
val message = textMessage.body?.trim() ?: ""
val messageTime = TextUtils
.timestampToTime(context, formatter, mInteractions[position].timestamp)
val timePermanent = convViewHolder.mMsgDetailTxtPerm
// Add margin if message need to be separated.
val isMessageSeparationNeeded = isMessageSeparationNeeded(isDateShown, position)
convViewHolder.mMessageLayout?.updateLayoutParams<MarginLayoutParams> {
topMargin = if (!isMessageSeparationNeeded) 0 else context.resources
.getDimensionPixelSize(R.dimen.conversation_message_separation)
}
messageBubble.background?.setTintList(null)
// Manage the update of the timestamp
if (isDateShown) {
convViewHolder.compositeDisposable.add(timestampUpdateTimer.subscribe {
timePermanent?.text = TextUtils
.timestampToDate(context, formatter, interaction.timestamp)
})
convViewHolder.mMsgDetailTxtPerm?.visibility = View.VISIBLE
} else convViewHolder.mMsgDetailTxtPerm?.visibility = View.GONE
// If in a replying bubble, we need to overlap the message bubble
// with the answered message bubble.
if (textMessage.replyToId != null) {
val paddingInDp = (1.5 * context.resources.displayMetrics.density).toInt()
messageBubbleBorder.setPadding(paddingInDp, paddingInDp, 0, 0)
messageBubbleBorder.updateLayoutParams<MarginLayoutParams> {
topMargin = context.resources
.getDimensionPixelSize(R.dimen.conversation_reply_overlap)
}
} else {
messageBubbleBorder.setPadding(0, 0, 0, 0)
messageBubbleBorder.updateLayoutParams<MarginLayoutParams> { topMargin = 0 }
}
// Manage the background of the message bubble.
updateMessageBackground(
context, messageBubble, messageBubbleBorder, msgSequenceType,
isOnlyEmoji = StringUtils.isOnlyEmoji(message),
isReplying = interaction.replyTo != null,
isDeleted = isDeleted,
isIncoming = textMessage.isIncoming
)
// Manage the left margin of the message bubble.
updateMessageLeftMargin(
context, messageBubbleBorder, replyBubble,
isGroup = presenter.isGroup(),
isIncoming = textMessage.isIncoming
)
// Manage long press.
messageBubble.setOnLongClickListener { v: View ->
openItemMenu(convViewHolder, v, interaction)
conversationFragment.updatePosition(convViewHolder.bindingAdapterPosition)
if (textMessage.isIncoming) {
messageBubble.background?.setTint(context.getColor(R.color.grey_500))
} else {
messageBubble.background?.setTint(convColorTint)
}
mCurrentLongItem = RecyclerViewContextMenuInfo(
convViewHolder.bindingAdapterPosition,
v.id.toLong()
)
true
}
// Manage the message content.
if (StringUtils.isOnlyEmoji(message)) {
messageContent.updateEmoji(message, messageTime, isEdited)
} else {
messageContent.updateStandard(
markwon.toMarkdown(message), messageTime, isEdited
)
// Manage layout for message with a link inside.
if (showLinkPreviews && !isDeleted) {
val cachedPreview =
textMessage.preview as? Maybe<PreviewData>? ?: LinkPreview.getFirstUrl(
message
)
.flatMap { url -> LinkPreview.load(url) }
.cache()
.apply { interaction.preview = this }
convViewHolder.compositeDisposable.add(cachedPreview
.observeOn(DeviceUtils.uiScheduler)
.subscribe({ data ->
Log.w(TAG, "got preview $data")
val image = convViewHolder.mImage ?: return@subscribe
if (data.imageUrl.isNotEmpty()) {
Glide.with(context)
.load(data.imageUrl)
.centerCrop()
.into(image)
image.visibility = View.VISIBLE
} else {
image.visibility = View.GONE
}
convViewHolder.mHistTxt?.text = data.title
if (data.description.isNotEmpty()) {
convViewHolder.mHistDetailTxt?.visibility = View.VISIBLE
convViewHolder.mHistDetailTxt?.text = data.description
} else {
convViewHolder.mHistDetailTxt?.visibility = View.GONE
}
answerLayout?.visibility = View.VISIBLE
val url = Uri.parse(data.baseUrl)
convViewHolder.mPreviewDomain?.text = url.host
answerLayout?.setOnClickListener {
context.startActivity(Intent(Intent.ACTION_VIEW, url))
}
}) { e -> Log.e(TAG, "Can't load preview", e) })
} else answerLayout?.visibility = View.GONE
}
// msgTxt.movementMethod = LinkMovementMethod.getInstance()
val endOfSeq =
msgSequenceType == SequenceType.LAST || msgSequenceType == SequenceType.SINGLE
// Manage animation for avatar and name.
val avatar = convViewHolder.mAvatar
if (presenter.isGroup() && textMessage.isIncoming) {
avatar?.let {
if (endOfSeq) { // To only display the avatar of the last message.
avatar.setImageDrawable(
conversationFragment.getConversationAvatar(contact.primaryNumber)
)
avatar.visibility = View.VISIBLE
} else {
if (position == lastMsgPos - 1) {
ActionHelper.startFadeOutAnimation(avatar)
} else {
avatar.setImageBitmap(null)
avatar.visibility = View.INVISIBLE
}
}
}
// Show the name of the contact.
val startOfSeq = msgSequenceType == SequenceType.FIRST
|| msgSequenceType == SequenceType.SINGLE
peerDisplayName?.apply {
if (startOfSeq) {
visibility = View.VISIBLE
convViewHolder.compositeDisposable.add(
presenter.contactService
.observeContact(account, contact, false)
.observeOn(DeviceUtils.uiScheduler)
.subscribe { text = it.displayName }
)
} else {
visibility = View.GONE
text = null
}
}
} else {
avatar?.visibility = View.GONE
peerDisplayName?.visibility = View.GONE
}
// Manage deleted message.
if (isDeleted) {
replyBubble?.visibility = View.GONE
messageContent.updateDeleted(messageTime)
messageBubble.setOnLongClickListener(null)
}
})
}
private fun configureForContactEvent(
viewHolder: ConversationViewHolder,
interaction: Interaction
) {
val context = viewHolder.itemView.context
val event = interaction as ContactEvent
Log.w(
TAG,
"configureForContactEvent ${event.account} ${event.event} ${event.contact} ${event.author} "
)
val timestamp = TextUtils.timestampToTime(context, formatter, event.timestamp)
if (interaction.isSwarm) {
viewHolder.compositeDisposable.add(
presenter.contactService.observeContact(event.account!!, event.contact!!, false)
.observeOn(DeviceUtils.uiScheduler)
.subscribe { vm ->
val eventString = context.getString(when (event.event) {
ContactEvent.Event.ADDED -> R.string.conversation_contact_added
ContactEvent.Event.INVITED -> R.string.conversation_contact_invited
ContactEvent.Event.REMOVED -> R.string.conversation_contact_left
ContactEvent.Event.BANNED -> R.string.conversation_contact_banned
else -> R.string.hist_contact_added
}, vm.displayName)
viewHolder.mMsgTxt?.text = "$eventString, $timestamp"
})
} else {
val eventString = when (event.event) {
ContactEvent.Event.ADDED -> R.string.hist_contact_added
ContactEvent.Event.INVITED -> R.string.hist_contact_invited
ContactEvent.Event.REMOVED -> R.string.hist_contact_left
ContactEvent.Event.BANNED -> R.string.hist_contact_banned
ContactEvent.Event.INCOMING_REQUEST -> R.string.hist_invitation_received
else -> R.string.hist_contact_added
}
viewHolder.mMsgTxt?.text = "$eventString, $timestamp"
}
}
/**
* Message Separation is used to highlight two group of messages.
* We don't need message separation if:
* - The message is the first of the conversation
* - The message is the first of the day (date already shown)
*/
private fun isMessageSeparationNeeded(
isDateShown: Boolean,
messagePosition: Int,
): Boolean = getPreviousInteractionFromPosition(messagePosition)?.let { firstInteraction ->
val secondInteraction = mInteractions[messagePosition]
!isDateShown && isSeqBreak(firstInteraction, secondInteraction)
} ?: false
/**
* Configures the viewHolder to display a call info text message, ie. not a classic text message
*
* @param convViewHolder The conversation viewHolder
* @param interaction The conversation element to display
*/
private fun configureForCallInfo(
convViewHolder: ConversationViewHolder,
interaction: Interaction,
position: Int
) {
val recycle: StringBuilder = StringBuilder()
val context = convViewHolder.itemView.context
// Reset the scale of the icon
convViewHolder.mIcon?.scaleX = 1f
// In the case were it is not a swarm (legacy call or SIP?)
if (!interaction.isSwarm) {
convViewHolder.mCallInfoLayout?.apply {
background?.setTintList(null) // Remove the tint
// Define Context Menu, call when long pressed (see definition below)
setOnCreateContextMenuListener { menu: ContextMenu, v: View, menuInfo:
ContextMenuInfo? ->
conversationFragment.onCreateContextMenu(menu, v, menuInfo)
// Inflate the view and set it up
val inflater = conversationFragment.requireActivity().menuInflater
inflater.inflate(R.menu.conversation_item_actions_messages, menu)
menu.findItem(R.id.conv_action_delete).setTitle(R.string.menu_delete)
menu.removeItem(R.id.conv_action_cancel_message)
menu.removeItem(R.id.conv_action_copy_text)
}
// When long clicked...
setOnLongClickListener { v: View ->
background?.setTint(context.getColor(R.color.grey_500))
// Open Context Menu
conversationFragment.updatePosition(convViewHolder.adapterPosition)
mCurrentLongItem =
RecyclerViewContextMenuInfo(convViewHolder.adapterPosition, v.id.toLong())
false
}
}
}
convViewHolder.compositeDisposable.add(
interaction.lastElement
.observeOn(DeviceUtils.uiScheduler)
.subscribe { lastElement ->
val call = lastElement as Call
val peerDisplayName = convViewHolder.mPeerDisplayName
val avatar = convViewHolder.mAvatar
val timePermanent = convViewHolder.mMsgDetailTxtPerm
val account = interaction.account ?: return@subscribe
val contact = call.contact ?: return@subscribe
val isTimeShown = hasPermanentTimeString(call, position)
val msgSequenceType = getMsgSequencing(position, isTimeShown)
val endOfSeq = msgSequenceType == SequenceType.LAST
|| msgSequenceType == SequenceType.SINGLE
val startOfSeq = msgSequenceType == SequenceType.FIRST
|| msgSequenceType == SequenceType.SINGLE
val resIndex = msgSequenceType.ordinal + (if (call.isIncoming) 1 else 0) * 4
// Only show the avatar if it is a group conversation
if (presenter.isGroup()) {
// Manage animation for avatar.
// To only display the avatar of the last message.
if (endOfSeq) {
avatar?.setImageDrawable(
conversationFragment.getConversationAvatar(contact.primaryNumber)
)
avatar?.visibility = View.VISIBLE
} else {
if (position == lastMsgPos - 1) {
avatar?.let { ActionHelper.startFadeOutAnimation(avatar) }
} else {
avatar?.setImageBitmap(null)
avatar?.visibility = View.INVISIBLE
}
}
} else avatar?.visibility = View.GONE
// Show the name of the contact if it is a group conversation
peerDisplayName?.apply {
if (presenter.isGroup() && startOfSeq) {
visibility = View.VISIBLE
convViewHolder.compositeDisposable.add(
presenter.contactService
.observeContact(account, contact, false)
.observeOn(DeviceUtils.uiScheduler)
.subscribe { text = it.displayName }
)
} else visibility = View.GONE
}
// Manage the update of the timestamp
if (isTimeShown) {
convViewHolder.compositeDisposable.add(timestampUpdateTimer.subscribe {
timePermanent?.text = TextUtils
.timestampToDetailString(context, formatter, call.timestamp)
})
convViewHolder.mMsgDetailTxtPerm?.visibility = View.VISIBLE
} else convViewHolder.mMsgDetailTxtPerm?.visibility = View.GONE
// When a group call is occurring but you are not in it, a message is displayed
// in conversation to inform the user about the call and invite him to join.
if (call.isGroupCall) {
val callAcceptLayout = convViewHolder.mCallAcceptLayout ?: return@subscribe
val callInfoText = convViewHolder.mCallInfoText ?: return@subscribe
val acceptCallAudioButton =
convViewHolder.mAcceptCallAudioButton ?: return@subscribe
val acceptCallVideoButton =
convViewHolder.mAcceptCallVideoButton ?: return@subscribe
callAcceptLayout.apply {
// Accept with audio only
convViewHolder.mAcceptCallAudioButton?.setOnClickListener {
call.confId?.let { presenter.goToGroupCall(false) }
}
// Accept call with video
convViewHolder.mAcceptCallVideoButton?.setOnClickListener {
call.confId?.let { presenter.goToGroupCall(true) }
}
}
// Set the background to the call started message.
callAcceptLayout.background =
ContextCompat.getDrawable(context, msgBGLayouts[resIndex])
if (call.isIncoming) {
// Show the avatar of the caller if last or single.
// Manage animation to only display the avatar of the last message.
if (endOfSeq) {
avatar?.setImageDrawable(
conversationFragment
.getConversationAvatar(contact.primaryNumber)
)
avatar?.visibility = View.VISIBLE
} else {
if (position == lastMsgPos - 1) {
avatar?.let { ActionHelper.startFadeOutAnimation(avatar) }
} else {
avatar?.setImageBitmap(null)
avatar?.visibility = View.INVISIBLE
}
}
// We can call ourselves in a group call with different devices.
// Set the message to the left when it is incoming.
convViewHolder.mGroupCallLayout?.gravity = Gravity.START
// Show the name of the contact.
peerDisplayName?.apply {
if (startOfSeq) {
visibility = View.VISIBLE
convViewHolder.compositeDisposable.add(
presenter.contactService
.observeContact(account, contact, false)
.observeOn(DeviceUtils.uiScheduler)
.subscribe {
text = it.displayName
}
)
} else visibility = View.GONE
}
// Use the original color of the icons.
callInfoText.setTextColor(context.getColor(R.color.colorOnSurface))
acceptCallAudioButton
.setColorFilter(context.getColor(R.color.accept_call_button))
acceptCallVideoButton
.setColorFilter(context.getColor(R.color.accept_call_button))
callAcceptLayout.background.setTint(
context.getColor(R.color.conversation_secondary_background)
)
} else {
// Set the message to the right because it is outgoing.
convViewHolder.mGroupCallLayout?.gravity = Gravity.END
// Hide the name of the contact.
peerDisplayName?.visibility = View.GONE
avatar?.visibility = View.GONE
// Set the color to the call started message.
if (convColor != 0) {
callAcceptLayout.background.setTint(convColor)
callInfoText.setTextColor(
context.getColor(R.color.text_color_primary_dark)
)
acceptCallAudioButton.setColorFilter(
context.getColor(R.color.white)
)
acceptCallVideoButton.setColorFilter(
context.getColor(R.color.white)
)
callAcceptLayout.background.setTint(convColor)
}
}
callAcceptLayout.setPadding(callPadding)
} else {
val typeCall = convViewHolder.mHistTxt ?: return@subscribe
val callInfoLayout = convViewHolder.mCallInfoLayout ?: return@subscribe
val detailCall = convViewHolder.mHistDetailTxt ?: return@subscribe
val callIcon = convViewHolder.mIcon ?: return@subscribe
val callLayout = convViewHolder.mCallLayout
val typeCallTxt: String
callInfoLayout.background =
ContextCompat.getDrawable(context, msgBGLayouts[resIndex])
callInfoLayout.setPadding(callPadding)
// Manage background to convColor if it is outgoing and not missed.
if (convColor != 0 && !call.isIncoming) {
callInfoLayout.background.setTint(convColor)
} else {
callInfoLayout.background.setTintList(null)
}
// Add the call duration if not null.
detailCall.text =
if (call.duration != 0L) {
String.format(
context.getString(R.string.call_duration),
DateUtils.formatElapsedTime(
recycle, call.duration!! / 1000
)
).let { " - $it" }
} else null
// After a call, a message is displayed with call information.
// Manage the call message layout.
if (call.isIncoming) {
// Set the color of the time duration.
detailCall.setTextColor(
context.getColor(R.color.colorOnSurface)
)
// Set the call message color.
typeCall.setTextColor(
context.getColor(R.color.colorOnSurface)
)
// Put the message to the left because it is incoming.
convViewHolder.mCallLayout?.gravity = Gravity.START
if (call.isMissed) { // Call incoming missed.
callIcon.setImageResource(R.drawable.baseline_missed_call_16)
// Set the drawable color to red because it is missed.
callIcon.drawable.setTint(context.getColor(R.color.call_missed))
typeCallTxt = context.getString(R.string.notif_missed_incoming_call)
} else { // Call incoming not missed.
callIcon.setImageResource(R.drawable.baseline_incoming_call_16)
callIcon.drawable.setTint(context.getColor(R.color.colorOnSurface))
typeCallTxt = context.getString(R.string.notif_incoming_call)
}
} else {
// Set the call message color.
typeCall.setTextColor(
context.getColor(R.color.call_text_outgoing_message)
)
// Set the color of the time duration.
detailCall.setTextColor(
context.getColor(R.color.call_text_outgoing_message)
)
if (call.isMissed) { // Outgoing call missed.
callIcon.setImageResource(R.drawable.baseline_missed_call_16)
// Set the drawable color to red because it is missed.
callIcon.drawable.setTint(context.getColor(R.color.call_missed))
typeCallTxt = context.getString(R.string.notif_missed_outgoing_call)
// Flip the photo upside down to show a "missed outgoing call".
callIcon.scaleX = -1f
} else { // Outgoing call not missed.
callIcon.setImageResource(R.drawable.baseline_outgoing_call_16)
callIcon.drawable
.setTint(context.getColor(R.color.call_drawable_color))
typeCallTxt = context.getString(R.string.notif_outgoing_call)
}
// Put the message to the right because it is outgoing.
callLayout?.gravity = Gravity.END
}
typeCall.text = typeCallTxt
}
}
)
}
private fun configureSearchResult(
convViewHolder: ConversationViewHolder,
interaction: Interaction
) {
disableClicking(convViewHolder.itemView)
val messageId = interaction.messageId ?: return
val clickView = convViewHolder.primaryClickableView ?: convViewHolder.itemView
clickView.setOnClickListener { conversationFragment.goToSearchMessage(messageId) }
}
private fun disableClicking(v: View) {
v.isClickable = false
v.isLongClickable = false
v.isFocusable = false
if (v is ViewGroup) {
v.children.forEach {
disableClicking(it)
}
}
}
/**
* Helper method to return the previous Interaction relative to an initial position.
*
* @param position The initial position
* @return the previous Interaction if any, null otherwise
*/
private fun getPreviousInteractionFromPosition(position: Int): Interaction? =
if (mInteractions.isNotEmpty() && position > 0) {
if (mInteractions[position - 1].type == Interaction.InteractionType.INVALID) {
// Recursive function to ignore invalid interactions.
getPreviousInteractionFromPosition(position - 1)
} else mInteractions[position - 1]
} else null
/**
* Helper method to return the next Interaction relative to an initial position.
*
* @param position The initial position
* @return the next Interaction if any, null otherwise
*/
private fun getNextInteractionFromPosition(position: Int): Interaction? =
if (mInteractions.isNotEmpty() && position < mInteractions.size - 1) {
if (mInteractions[position + 1].type == Interaction.InteractionType.INVALID) {
// Recursive function to ignore invalid interactions.
getNextInteractionFromPosition(position + 1)
} else mInteractions[position + 1]
} else null
/**
* Returns a SequenceType object which tell what type is the Interaction.
*
* @param i index of the interaction to analyse from interactions array.
* @param isTimeShown meta data of the interaction telling if the time is shown.
* @return the SequenceType of the analyzed interaction.
*/
private fun getMsgSequencing(i: Int, isTimeShown: Boolean): SequenceType {
val msg = mInteractions[i]
// Manage specific interaction which are always single (ex : emoji).
if (isAlwaysSingleMsg(msg.lastElement.blockingFirst())) {
return SequenceType.SINGLE
}
// Check for extremes (first or last or only one interaction).
if (getPreviousInteractionFromPosition(i) == null) {
// Get the next interaction (if null it means there is only one interaction).
val nextMsg = getNextInteractionFromPosition(i) ?: return SequenceType.SINGLE
// Check if sequence break needed.
return if (isSeqBreak(msg, nextMsg) || hasPermanentDateString(nextMsg, i + 1))
SequenceType.SINGLE
else
SequenceType.FIRST
} else if (getNextInteractionFromPosition(i) == null) {
// Get the previous interaction and if exists check if sequence break needed.
val prevMsg = getPreviousInteractionFromPosition(i)
if (prevMsg != null)
return if (isSeqBreak(prevMsg, msg) || isTimeShown)
SequenceType.SINGLE
else SequenceType.LAST
}
// If not the first, nor the last and if there is not only one interaction.
// Get the next and previous interactions and if exists check if sequence break needed.
val prevMsg = getPreviousInteractionFromPosition(i)
val nextMsg = getNextInteractionFromPosition(i)
if (prevMsg != null && nextMsg != null) {
val nextMsgHasDate = hasPermanentDateString(nextMsg, i + 1)
return if ((isSeqBreak(prevMsg, msg) || isTimeShown)
&& !(isSeqBreak(msg, nextMsg) || nextMsgHasDate)
) {
SequenceType.FIRST
} else if (!isSeqBreak(prevMsg, msg) && !isTimeShown && isSeqBreak(msg, nextMsg)) {
SequenceType.LAST
} else if (!isSeqBreak(prevMsg, msg) && !isTimeShown && !isSeqBreak(msg, nextMsg)) {
if (nextMsgHasDate) SequenceType.LAST else SequenceType.MIDDLE
} else {
SequenceType.SINGLE
}
}
return SequenceType.SINGLE
}
private fun setItemViewExpansionState(viewHolder: ConversationViewHolder, expanded: Boolean) {
val view: View = viewHolder.mMsgDetailTxt ?: return
if (view.height == 0 && !expanded) return
(viewHolder.animator ?: ValueAnimator().apply {
duration = 200
addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
val va = animation as ValueAnimator
if (va.animatedValue as Int == 0) {
view.visibility = View.GONE
}
viewHolder.animator = null
}
})
addUpdateListener { animation: ValueAnimator ->
view.layoutParams.height = (animation.animatedValue as Int)
view.requestLayout()
}
viewHolder.animator = this
}).apply {
if (isRunning) {
reverse()
} else {
view.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED)
setIntValues(0, view.measuredHeight)
}
if (expanded) {
view.visibility = View.VISIBLE
start()
} else {
reverse()
}
}
}
private fun hasPermanentTimeString(msg: Interaction, position: Int): Boolean {
val prevMsg = getPreviousInteractionFromPosition(position)
return prevMsg != null && msg.timestamp - prevMsg.timestamp > 10 * DateUtils.MINUTE_IN_MILLIS
}
// Used to show the date between messages.
private fun hasPermanentDateString(message: Interaction, position: Int): Boolean {
val previousMessageTimestamp =
getPreviousInteractionFromPosition(position)?.timestamp ?: return false
// Create Calendar instances for each timestamp
val calendar1 = Calendar.getInstance().apply { timeInMillis = message.timestamp }
val calendar2 = Calendar.getInstance().apply { timeInMillis = previousMessageTimestamp }
// Compare the year, month, and day of year to check if they are different days
return calendar1.get(Calendar.YEAR) != calendar2.get(Calendar.YEAR)
|| calendar1.get(Calendar.DAY_OF_YEAR) != calendar2.get(Calendar.DAY_OF_YEAR)
}
private enum class SequenceType { FIRST, MIDDLE, LAST, SINGLE }
companion object {
private val TAG = ConversationAdapter::class.simpleName!!
private val msgBGLayouts = intArrayOf(
R.drawable.textmsg_bg_out_first,
R.drawable.textmsg_bg_out_middle,
R.drawable.textmsg_bg_out_last,
R.drawable.textmsg_bg_out,
R.drawable.textmsg_bg_in_first,
R.drawable.textmsg_bg_in_middle,
R.drawable.textmsg_bg_in_last,
R.drawable.textmsg_bg_in,
R.drawable.textmsg_bg_out_reply,
R.drawable.textmsg_bg_out_reply_first,
R.drawable.textmsg_bg_in_reply,
R.drawable.textmsg_bg_in_reply_first
)
/**
* Tells if a break should be added in the sequence.
* The first interaction must be before the second interaction.
*
* @param first first interaction
* @param second second interaction
* @return True if a sequence break is needed. Else false.
*/
private fun isSeqBreak(first: Interaction, second: Interaction): Boolean {
val lastElementFirst = first.lastElement.blockingFirst()
val lastElementSecond = second.lastElement.blockingFirst()
return StringUtils.isOnlyEmoji(lastElementFirst.body) != StringUtils.isOnlyEmoji(lastElementSecond.body)
|| first.isIncoming != second.isIncoming
|| ((first.type !== Interaction.InteractionType.TEXT) && (first.type !== Interaction.InteractionType.CALL))
|| ((second.type !== Interaction.InteractionType.TEXT) && (second.type !== Interaction.InteractionType.CALL))
|| second.replyTo != null
|| first.contact != second.contact
|| (second.timestamp - first.timestamp) > (10 * DateUtils.MINUTE_IN_MILLIS)
}
private fun isAlwaysSingleMsg(msg: Interaction): Boolean =
((msg.type !== Interaction.InteractionType.TEXT && msg.type !== Interaction.InteractionType.CALL)
|| StringUtils.isOnlyEmoji(msg.body))
}
}