Started to add chat during call

This commit is contained in:
Sylvain Berfini 2024-04-08 11:39:11 +02:00
parent e805fbc7f3
commit d19f08cf86
21 changed files with 1122 additions and 60 deletions

View file

@ -233,6 +233,28 @@ class CallActivity : GenericActivity() {
}
}
override fun onStart() {
super.onStart()
findNavController(R.id.call_nav_container).addOnDestinationChangedListener { _, destination, _ ->
val showTopBar = when (destination.id) {
R.id.inCallConversationFragment, R.id.transferCallFragment, R.id.newCallFragment -> true
else -> false
}
callsViewModel.showTopBar.postValue(showTopBar)
}
}
override fun onResume() {
super.onResume()
val isInPipMode = isInPictureInPictureMode
if (::callViewModel.isInitialized) {
Log.i("$TAG onResume: is in PiP mode? $isInPipMode")
callViewModel.pipMode.value = isInPipMode
}
}
override fun onPause() {
super.onPause()
@ -249,16 +271,6 @@ class CallActivity : GenericActivity() {
}
}
override fun onResume() {
super.onResume()
val isInPipMode = isInPictureInPictureMode
if (::callViewModel.isInitialized) {
Log.i("$TAG onResume: is in PiP mode? $isInPipMode")
callViewModel.pipMode.value = isInPipMode
}
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
@ -323,7 +335,7 @@ class CallActivity : GenericActivity() {
)
}
private fun showRedToast(
fun showRedToast(
message: String,
@DrawableRes icon: Int,
duration: Long = 4000,

View file

@ -330,6 +330,33 @@ class ActiveCallFragment : GenericCallFragment() {
}
}
}
callViewModel.chatRoomCreationErrorEvent.observe(viewLifecycleOwner) {
it.consume { error ->
(requireActivity() as CallActivity).showRedToast(
error,
R.drawable.x
)
}
}
callViewModel.goToConversationEvent.observe(viewLifecycleOwner) {
it.consume { pair ->
if (findNavController().currentDestination?.id == R.id.activeCallFragment) {
val localSipUri = pair.first
val remoteSipUri = pair.second
Log.i(
"$TAG Display conversation with local SIP URI [$localSipUri] and remote SIP URI [$remoteSipUri]"
)
val action =
ActiveCallFragmentDirections.actionActiveCallFragmentToInCallConversationFragment(
localSipUri,
remoteSipUri
)
findNavController().navigate(action)
}
}
}
}
@SuppressLint("ClickableViewAccessibility")

View file

@ -99,7 +99,7 @@ class CallsListFragment : GenericCallFragment() {
}
binding.setMergeCallsClickListener {
viewModel.mergeCallsIntoLocalConference()
viewModel.mergeCallsIntoConference()
}
viewModel.calls.observe(viewLifecycleOwner) {

View file

@ -0,0 +1,780 @@
/*
* Copyright (c) 2010-2024 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* 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 <http://www.gnu.org/licenses/>.
*/
package org.linphone.ui.call.fragment
import android.app.Dialog
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.Window
import android.view.WindowManager
import androidx.annotation.UiThread
import androidx.core.view.doOnPreDraw
import androidx.databinding.DataBindingUtil
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.google.android.material.tabs.TabLayout
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.R
import org.linphone.compatibility.Compatibility
import org.linphone.core.ChatMessage
import org.linphone.core.tools.Log
import org.linphone.databinding.ChatBubbleLongPressMenuBinding
import org.linphone.databinding.ChatConversationFragmentBinding
import org.linphone.ui.call.CallActivity
import org.linphone.ui.main.chat.ConversationScrollListener
import org.linphone.ui.main.chat.adapter.ConversationEventAdapter
import org.linphone.ui.main.chat.adapter.MessageBottomSheetAdapter
import org.linphone.ui.main.chat.fragment.ConversationFragmentArgs
import org.linphone.ui.main.chat.fragment.EndToEndEncryptionDetailsDialogFragment
import org.linphone.ui.main.chat.model.MessageDeliveryModel
import org.linphone.ui.main.chat.model.MessageModel
import org.linphone.ui.main.chat.model.MessageReactionsModel
import org.linphone.ui.main.chat.view.RichEditText
import org.linphone.ui.main.chat.viewmodel.ConversationViewModel
import org.linphone.ui.main.chat.viewmodel.SendMessageInConversationViewModel
import org.linphone.utils.RecyclerViewHeaderDecoration
import org.linphone.utils.RecyclerViewSwipeUtils
import org.linphone.utils.RecyclerViewSwipeUtilsCallback
import org.linphone.utils.addCharacterAtPosition
import org.linphone.utils.hideKeyboard
import org.linphone.utils.setKeyboardInsetListener
import org.linphone.utils.showKeyboard
class ConversationFragment : GenericCallFragment() {
companion object {
private const val TAG = "[In-call Conversation Fragment]"
}
private lateinit var binding: ChatConversationFragmentBinding
private lateinit var viewModel: ConversationViewModel
private lateinit var sendMessageViewModel: SendMessageInConversationViewModel
private lateinit var adapter: ConversationEventAdapter
private lateinit var bottomSheetAdapter: MessageBottomSheetAdapter
private var messageLongPressDialog: Dialog? = null
private val args: ConversationFragmentArgs by navArgs()
private var bottomSheetDialog: BottomSheetDialogFragment? = null
private val dataObserver = object : RecyclerView.AdapterDataObserver() {
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
if (positionStart > 0) {
adapter.notifyItemChanged(positionStart - 1) // For grouping purposes
}
if (viewModel.isUserScrollingUp.value == true) {
Log.i(
"$TAG [$itemCount] events have been loaded but user was scrolling up in conversation, do not scroll"
)
return
}
if (positionStart == 0 && adapter.itemCount == itemCount) {
// First time we fill the list with messages
Log.i(
"$TAG [$itemCount] events have been loaded"
)
} else {
Log.i(
"$TAG [$itemCount] new events have been loaded, scrolling to first unread message"
)
scrollToFirstUnreadMessageOrBottom()
}
}
}
private lateinit var scrollListener: ConversationScrollListener
private lateinit var headerItemDecoration: RecyclerViewHeaderDecoration
private val listItemTouchListener = object : RecyclerView.OnItemTouchListener {
override fun onInterceptTouchEvent(
rv: RecyclerView,
e: MotionEvent
): Boolean {
// Following code is only to detect click on header at position 0
if (::headerItemDecoration.isInitialized) {
if (e.action == MotionEvent.ACTION_UP) {
if ((rv.layoutManager as LinearLayoutManager).findFirstCompletelyVisibleItemPosition() == 0) {
if (e.y >= 0 && e.y <= headerItemDecoration.getDecorationHeight(0)) {
showEndToEndEncryptionDetailsBottomSheet()
return true
}
}
}
}
return false
}
override fun onTouchEvent(rv: RecyclerView, e: MotionEvent) { }
override fun onRequestDisallowInterceptTouchEvent(
disallowIntercept: Boolean
) { }
}
private var currentChatMessageModelForBottomSheet: MessageModel? = null
private val bottomSheetCallback = object : BottomSheetBehavior.BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) {
if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
currentChatMessageModelForBottomSheet?.isSelected?.value = false
}
}
override fun onSlide(bottomSheet: View, slideOffset: Float) { }
}
private var bottomSheetDeliveryModel: MessageDeliveryModel? = null
private var bottomSheetReactionsModel: MessageReactionsModel? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
adapter = ConversationEventAdapter()
headerItemDecoration = RecyclerViewHeaderDecoration(
requireContext(),
adapter,
false
)
bottomSheetAdapter = MessageBottomSheetAdapter()
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = ChatConversationFragmentBinding.inflate(layoutInflater)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
postponeEnterTransition()
super.onViewCreated(view, savedInstanceState)
binding.lifecycleOwner = viewLifecycleOwner
viewModel = ViewModelProvider(this)[ConversationViewModel::class.java]
sendMessageViewModel =
ViewModelProvider(this)[SendMessageInConversationViewModel::class.java]
viewModel.isInCallConversation.value = true
binding.viewModel = viewModel
sendMessageViewModel.isInCallConversation.value = true
binding.sendMessageViewModel = sendMessageViewModel
binding.setBackClickListener {
findNavController().popBackStack()
}
binding.eventsList.setHasFixedSize(true)
val layoutManager = LinearLayoutManager(requireContext())
layoutManager.stackFromEnd = true
binding.eventsList.layoutManager = layoutManager
if (binding.eventsList.adapter != adapter) {
binding.eventsList.adapter = adapter
}
val callbacks = RecyclerViewSwipeUtilsCallback(
R.drawable.reply,
ConversationEventAdapter.EventViewHolder::class.java
) { viewHolder ->
val index = viewHolder.bindingAdapterPosition
if (index < 0 || index >= adapter.currentList.size) {
Log.e("$TAG Swipe viewHolder index [$index] is out of bounds!")
} else {
adapter.notifyItemChanged(index)
if (viewModel.isReadOnly.value == true || viewModel.isDisabledBecauseNotSecured.value == true) {
Log.w("$TAG Do not handle swipe action because conversation is read only")
return@RecyclerViewSwipeUtilsCallback
}
val chatMessageEventLog = adapter.currentList[index]
val chatMessageModel = (chatMessageEventLog.model as? MessageModel)
if (chatMessageModel != null) {
sendMessageViewModel.replyToMessage(chatMessageModel)
// Open keyboard & focus edit text
binding.sendArea.messageToSend.showKeyboard()
} else {
Log.e(
"$TAG Can't reply, failed to get a ChatMessageModel from adapter item #[$index]"
)
}
}
}
RecyclerViewSwipeUtils(callbacks).attachToRecyclerView(binding.eventsList)
val localSipUri = args.localSipUri
val remoteSipUri = args.remoteSipUri
Log.i(
"$TAG Looking up for conversation with local SIP URI [$localSipUri] and remote SIP URI [$remoteSipUri]"
)
viewModel.findChatRoom(null, localSipUri, remoteSipUri)
viewModel.chatRoomFoundEvent.observe(viewLifecycleOwner) {
it.consume { found ->
if (!found) {
(view.parent as? ViewGroup)?.doOnPreDraw {
Log.e("$TAG Failed to find conversation, going back")
findNavController().popBackStack()
val message = getString(R.string.toast_cant_find_conversation_to_display)
(requireActivity() as CallActivity).showRedToast(message, R.drawable.x)
}
} else {
sendMessageViewModel.configureChatRoom(viewModel.chatRoom)
(view.parent as? ViewGroup)?.doOnPreDraw {
startPostponedEnterTransition()
}
}
}
}
viewModel.updateEvents.observe(viewLifecycleOwner) {
val items = viewModel.eventsList
adapter.submitList(items)
Log.i("$TAG Events (messages) list updated, contains [${items.size}] items")
}
viewModel.isEndToEndEncrypted.observe(viewLifecycleOwner) { encrypted ->
if (encrypted) {
binding.eventsList.addItemDecoration(headerItemDecoration)
binding.eventsList.addOnItemTouchListener(listItemTouchListener)
}
}
binding.messageBottomSheet.bottomSheetList.setHasFixedSize(true)
val bottomSheetLayoutManager = LinearLayoutManager(requireContext())
binding.messageBottomSheet.bottomSheetList.layoutManager = bottomSheetLayoutManager
adapter.chatMessageLongPressEvent.observe(viewLifecycleOwner) {
it.consume { model ->
showChatMessageLongPressMenu(model)
}
}
adapter.showDeliveryForChatMessageModelEvent.observe(viewLifecycleOwner) {
it.consume { model ->
showBottomSheetDialog(model, showDelivery = true)
}
}
adapter.showReactionForChatMessageModelEvent.observe(viewLifecycleOwner) {
it.consume { model ->
showBottomSheetDialog(model, showReactions = true)
}
}
adapter.scrollToRepliedMessageEvent.observe(viewLifecycleOwner) {
it.consume { model ->
val repliedMessageId = model.replyToMessageId
if (repliedMessageId.isNullOrEmpty()) {
Log.w("$TAG Message [${model.id}] doesn't have a reply to ID!")
} else {
val originalMessage = adapter.currentList.find { eventLog ->
!eventLog.isEvent && (eventLog.model as MessageModel).id == repliedMessageId
}
if (originalMessage != null) {
val position = adapter.currentList.indexOf(originalMessage)
Log.i("$TAG Scrolling to position [$position]")
binding.eventsList.scrollToPosition(position)
} else {
Log.w("$TAG Failed to find matching message in adapter's items!")
}
}
}
}
binding.setScrollToBottomClickListener {
scrollToFirstUnreadMessageOrBottom()
}
binding.setEndToEndEncryptedEventClickListener {
showEndToEndEncryptionDetailsBottomSheet()
}
sendMessageViewModel.emojiToAddEvent.observe(viewLifecycleOwner) {
it.consume { emoji ->
binding.sendArea.messageToSend.addCharacterAtPosition(emoji)
}
}
sendMessageViewModel.participantUsernameToAddEvent.observe(viewLifecycleOwner) {
it.consume { username ->
Log.i("$TAG Adding username [$username] after '@'")
// Also add a space for convenience
binding.sendArea.messageToSend.addCharacterAtPosition("$username ")
}
}
sendMessageViewModel.requestKeyboardHidingEvent.observe(viewLifecycleOwner) {
it.consume {
binding.search.hideKeyboard()
}
}
sendMessageViewModel.showRedToastEvent.observe(viewLifecycleOwner) {
it.consume { pair ->
val message = pair.first
val icon = pair.second
(requireActivity() as CallActivity).showRedToast(message, icon)
}
}
viewModel.searchFilter.observe(viewLifecycleOwner) { filter ->
viewModel.applyFilter(filter.trim())
}
viewModel.focusSearchBarEvent.observe(viewLifecycleOwner) {
it.consume { show ->
if (show) {
// To automatically open keyboard
binding.search.showKeyboard()
} else {
binding.search.hideKeyboard()
}
}
}
viewModel.openWebBrowserEvent.observe(viewLifecycleOwner) {
it.consume { url ->
if (messageLongPressDialog != null) return@consume
Log.i("$TAG Requesting to open web browser on page [$url]")
try {
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
startActivity(browserIntent)
} catch (e: Exception) {
Log.e(
"$TAG Can't start ACTION_VIEW intent for URL [$url]: $e"
)
}
}
}
viewModel.showRedToastEvent.observe(viewLifecycleOwner) {
it.consume { pair ->
val message = pair.first
val icon = pair.second
(requireActivity() as CallActivity).showRedToast(message, icon)
}
}
viewModel.messageDeletedEvent.observe(viewLifecycleOwner) {
it.consume {
val message = getString(R.string.conversation_message_deleted_toast)
val icon = R.drawable.x
(requireActivity() as CallActivity).showGreenToast(message, icon)
}
}
binding.sendArea.messageToSend.setControlEnterListener(object :
RichEditText.RichEditTextSendListener {
override fun onControlEnterPressedAndReleased() {
Log.i("$TAG Detected left control + enter key presses, sending message")
sendMessageViewModel.sendMessage()
}
})
binding.root.setKeyboardInsetListener { keyboardVisible ->
sendMessageViewModel.isKeyboardOpen.value = keyboardVisible
if (keyboardVisible) {
sendMessageViewModel.isEmojiPickerOpen.value = false
}
}
scrollListener = object : ConversationScrollListener(layoutManager) {
@UiThread
override fun onLoadMore(totalItemsCount: Int) {
viewModel.loadMoreData(totalItemsCount)
}
@UiThread
override fun onScrolledUp() {
viewModel.isUserScrollingUp.value = true
}
@UiThread
override fun onScrolledToEnd() {
if (viewModel.isUserScrollingUp.value == true) {
viewModel.isUserScrollingUp.value = false
Log.i("$TAG Last message is visible, considering conversation as read")
viewModel.markAsRead()
}
}
}
binding.eventsList.addOnScrollListener(scrollListener)
}
override fun onResume() {
super.onResume()
viewModel.updateCurrentlyDisplayedConversation()
try {
adapter.registerAdapterDataObserver(dataObserver)
} catch (e: IllegalStateException) {
Log.e("$TAG Failed to register data observer to adapter: $e")
}
val bottomSheetBehavior = BottomSheetBehavior.from(binding.messageBottomSheet.root)
bottomSheetBehavior.addBottomSheetCallback(bottomSheetCallback)
}
override fun onPause() {
super.onPause()
bottomSheetDialog?.dismiss()
bottomSheetDialog = null
if (::scrollListener.isInitialized) {
binding.eventsList.removeOnScrollListener(scrollListener)
}
coreContext.postOnCoreThread {
bottomSheetReactionsModel?.destroy()
bottomSheetDeliveryModel?.destroy()
coreContext.notificationsManager.resetCurrentlyDisplayedChatRoomId()
}
try {
adapter.unregisterAdapterDataObserver(dataObserver)
} catch (e: IllegalStateException) {
Log.e("$TAG Failed to unregister data observer to adapter: $e")
}
val bottomSheetBehavior = BottomSheetBehavior.from(binding.messageBottomSheet.root)
bottomSheetBehavior.removeBottomSheetCallback(bottomSheetCallback)
currentChatMessageModelForBottomSheet = null
}
private fun scrollToFirstUnreadMessageOrBottom() {
if (adapter.itemCount == 0) return
val recyclerView = binding.eventsList
// Scroll to first unread message if any, unless we are already on it
val firstUnreadMessagePosition = adapter.getFirstUnreadMessagePosition()
val currentPosition = (recyclerView.layoutManager as LinearLayoutManager).findFirstCompletelyVisibleItemPosition()
val indexToScrollTo = if (firstUnreadMessagePosition != -1 && firstUnreadMessagePosition != currentPosition) {
firstUnreadMessagePosition
} else {
adapter.itemCount - 1
}
Log.i(
"$TAG Scrolling to position $indexToScrollTo, first unread message is at $firstUnreadMessagePosition"
)
recyclerView.scrollToPosition(indexToScrollTo)
if (indexToScrollTo == adapter.itemCount - 1) {
viewModel.isUserScrollingUp.postValue(false)
viewModel.markAsRead()
}
}
private fun dismissDialog() {
messageLongPressDialog?.dismiss()
messageLongPressDialog = null
}
private fun showChatMessageLongPressMenu(chatMessageModel: MessageModel) {
Compatibility.setBlurRenderEffect(binding.root)
val dialog = Dialog(requireContext(), R.style.Theme_LinphoneDialog)
dialog.requestWindowFeature(Window.FEATURE_NO_TITLE)
val layout: ChatBubbleLongPressMenuBinding = DataBindingUtil.inflate(
LayoutInflater.from(context),
R.layout.chat_bubble_long_press_menu,
null,
false
)
layout.hideForward = true
layout.root.setOnClickListener {
dismissDialog()
}
layout.setDeleteClickListener {
Log.i("$TAG Deleting message")
viewModel.deleteChatMessage(chatMessageModel)
dismissDialog()
}
layout.setCopyClickListener {
Log.i("$TAG Copying message text into clipboard")
val text = chatMessageModel.text.value?.toString()
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val label = "Message"
clipboard.setPrimaryClip(ClipData.newPlainText(label, text))
dismissDialog()
}
layout.setPickEmojiClickListener {
Log.i("$TAG Opening emoji-picker for reaction")
val emojiSheetBehavior = BottomSheetBehavior.from(layout.emojiPickerBottomSheet.root)
emojiSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED
}
layout.setResendClickListener {
Log.i("$TAG Re-sending message in error state")
chatMessageModel.resend()
dismissDialog()
}
layout.setReplyClickListener {
Log.i("$TAG Updating sending area to reply to selected message")
sendMessageViewModel.replyToMessage(chatMessageModel)
dismissDialog()
// Open keyboard & focus edit text
binding.sendArea.messageToSend.showKeyboard()
}
layout.model = chatMessageModel
chatMessageModel.dismissLongPressMenuEvent.observe(viewLifecycleOwner) {
dismissDialog()
}
dialog.setContentView(layout.root)
dialog.setOnDismissListener {
Compatibility.removeBlurRenderEffect(binding.root)
}
dialog.window
?.setLayout(
WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.MATCH_PARENT
)
val d: Drawable = ColorDrawable(
requireContext().getColor(R.color.grey_300)
)
d.alpha = 102
dialog.window?.setBackgroundDrawable(d)
dialog.show()
messageLongPressDialog = dialog
}
@UiThread
private fun showBottomSheetDialog(
chatMessageModel: MessageModel,
showDelivery: Boolean = false,
showReactions: Boolean = false
) {
val bottomSheetBehavior = BottomSheetBehavior.from(binding.messageBottomSheet.root)
bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
binding.messageBottomSheet.setHandleClickedListener {
bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
}
if (binding.messageBottomSheet.bottomSheetList.adapter != bottomSheetAdapter) {
binding.messageBottomSheet.bottomSheetList.adapter = bottomSheetAdapter
}
currentChatMessageModelForBottomSheet?.isSelected?.value = false
currentChatMessageModelForBottomSheet = chatMessageModel
chatMessageModel.isSelected.value = true
lifecycleScope.launch {
withContext(Dispatchers.IO) {
// Wait for previous bottom sheet to go away
delay(200)
withContext(Dispatchers.Main) {
if (showDelivery) {
prepareBottomSheetForDeliveryStatus(chatMessageModel)
} else if (showReactions) {
prepareBottomSheetForReactions(chatMessageModel)
}
bottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED
}
}
}
}
@UiThread
private fun prepareBottomSheetForDeliveryStatus(chatMessageModel: MessageModel) {
coreContext.postOnCoreThread {
bottomSheetDeliveryModel?.destroy()
val model = MessageDeliveryModel(chatMessageModel.chatMessage) { deliveryModel ->
coreContext.postOnMainThread {
displayDeliveryStatuses(deliveryModel)
}
}
bottomSheetDeliveryModel = model
}
}
@UiThread
private fun prepareBottomSheetForReactions(chatMessageModel: MessageModel) {
coreContext.postOnCoreThread {
bottomSheetReactionsModel?.destroy()
val model = MessageReactionsModel(chatMessageModel.chatMessage) { reactionsModel ->
coreContext.postOnMainThread {
if (reactionsModel.allReactions.isEmpty()) {
Log.i("$TAG No reaction to display, closing bottom sheet")
val bottomSheetBehavior = BottomSheetBehavior.from(
binding.messageBottomSheet.root
)
bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
} else {
displayReactions(reactionsModel)
}
}
}
bottomSheetReactionsModel = model
}
}
@UiThread
private fun displayDeliveryStatuses(model: MessageDeliveryModel) {
val tabs = binding.messageBottomSheet.tabs
tabs.removeAllTabs()
tabs.addTab(
tabs.newTab().setText(model.readLabel.value).setId(
ChatMessage.State.Displayed.toInt()
)
)
tabs.addTab(
tabs.newTab().setText(
model.receivedLabel.value
).setId(
ChatMessage.State.DeliveredToUser.toInt()
)
)
tabs.addTab(
tabs.newTab().setText(model.sentLabel.value).setId(
ChatMessage.State.Delivered.toInt()
)
)
tabs.addTab(
tabs.newTab().setText(
model.errorLabel.value
).setId(
ChatMessage.State.NotDelivered.toInt()
)
)
tabs.setOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
override fun onTabSelected(tab: TabLayout.Tab?) {
val state = tab?.id ?: ChatMessage.State.Displayed.toInt()
bottomSheetAdapter.submitList(
model.computeListForState(ChatMessage.State.fromInt(state))
)
}
override fun onTabUnselected(tab: TabLayout.Tab?) {
}
override fun onTabReselected(tab: TabLayout.Tab?) {
}
})
val initialList = model.displayedModels
bottomSheetAdapter.submitList(initialList)
Log.i("$TAG Submitted [${initialList.size}] items for default delivery status list")
}
@UiThread
private fun displayReactions(model: MessageReactionsModel) {
val totalCount = model.allReactions.size
val label = getString(R.string.message_reactions_info_all_title, totalCount.toString())
val tabs = binding.messageBottomSheet.tabs
tabs.removeAllTabs()
tabs.addTab(
tabs.newTab().setText(label).setId(0).setTag("")
)
var index = 1
for (reaction in model.differentReactions.value.orEmpty()) {
val count = model.reactionsMap[reaction]
val tabLabel = getString(
R.string.message_reactions_info_emoji_title,
reaction,
count.toString()
)
tabs.addTab(
tabs.newTab().setText(tabLabel).setId(index).setTag(reaction)
)
index += 1
}
tabs.setOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
override fun onTabSelected(tab: TabLayout.Tab?) {
val filter = tab?.tag.toString()
if (filter.isEmpty()) {
bottomSheetAdapter.submitList(model.allReactions)
} else {
bottomSheetAdapter.submitList(model.filterReactions(filter))
}
}
override fun onTabUnselected(tab: TabLayout.Tab?) {
}
override fun onTabReselected(tab: TabLayout.Tab?) {
}
})
val initialList = model.allReactions
bottomSheetAdapter.submitList(initialList)
Log.i("$TAG Submitted [${initialList.size}] items for default reactions list")
}
private fun showEndToEndEncryptionDetailsBottomSheet() {
val e2eEncryptionDetailsBottomSheet = EndToEndEncryptionDetailsDialogFragment()
e2eEncryptionDetailsBottomSheet.show(
requireActivity().supportFragmentManager,
EndToEndEncryptionDetailsDialogFragment.TAG
)
bottomSheetDialog = e2eEncryptionDetailsBottomSheet
}
}

View file

@ -43,6 +43,8 @@ class CallsViewModel @UiThread constructor() : ViewModel() {
val callsCount = MutableLiveData<Int>()
val showTopBar = MutableLiveData<Boolean>()
val goToActiveCallEvent = MutableLiveData<Event<Boolean>>()
val showIncomingCallEvent = MutableLiveData<Event<Boolean>>()
@ -51,9 +53,11 @@ class CallsViewModel @UiThread constructor() : ViewModel() {
val noCallFoundEvent = MutableLiveData<Event<Boolean>>()
val otherCallsLabel = MutableLiveData<String>()
val callsTopBarLabel = MutableLiveData<String>()
val otherCallsStatus = MutableLiveData<String>()
val callsTopBarIcon = MutableLiveData<Int>()
val callsTopBarStatus = MutableLiveData<String>()
val goToCallsListEvent: MutableLiveData<Event<Boolean>> by lazy {
MutableLiveData<Event<Boolean>>()
@ -149,6 +153,8 @@ class CallsViewModel @UiThread constructor() : ViewModel() {
}
init {
showTopBar.value = false
coreContext.postOnCoreThread { core ->
core.addListener(coreListener)
@ -198,12 +204,18 @@ class CallsViewModel @UiThread constructor() : ViewModel() {
}
@UiThread
fun goToCallsList() {
goToCallsListEvent.value = Event(true)
fun topBarClicked() {
coreContext.postOnCoreThread { core ->
if (core.callsNb == 1) {
goToActiveCallEvent.postValue(Event(core.calls.first().conference == null))
} else {
goToCallsListEvent.postValue(Event(true))
}
}
}
@UiThread
fun mergeCallsIntoLocalConference() {
fun mergeCallsIntoConference() {
// TODO FIXME: implement local conferences merge
}
@ -212,31 +224,46 @@ class CallsViewModel @UiThread constructor() : ViewModel() {
val core = coreContext.core
if (core.callsNb > 1) {
showTopBar.postValue(true)
if (core.callsNb == 2) {
val found = core.calls.find {
it.state == Call.State.Paused
}
callsTopBarIcon.postValue(R.drawable.phone_pause)
if (found != null) {
val contact = coreContext.contactsManager.findContactByAddress(
found.remoteAddress
)
otherCallsLabel.postValue(
callsTopBarLabel.postValue(
contact?.name ?: LinphoneUtils.getDisplayName(found.remoteAddress)
)
otherCallsStatus.postValue(LinphoneUtils.callStateToString(found.state))
callsTopBarStatus.postValue(LinphoneUtils.callStateToString(found.state))
} else {
Log.e("$TAG Failed to find a paused call")
}
} else {
otherCallsLabel.postValue(
callsTopBarLabel.postValue(
AppUtils.getFormattedString(R.string.calls_paused_count_label, core.callsNb - 1)
)
otherCallsStatus.postValue("") // TODO: improve ?
callsTopBarStatus.postValue("") // TODO: improve ?
}
Log.i("$TAG At least one other call, asking activity to change status bar color")
changeSystemTopBarColorToMultipleCallsEvent.postValue(Event(true))
} else {
if (core.callsNb == 1) {
callsTopBarIcon.postValue(R.drawable.phone)
val call = core.calls.first()
val contact = coreContext.contactsManager.findContactByAddress(
call.remoteAddress
)
callsTopBarLabel.postValue(
contact?.name ?: LinphoneUtils.getDisplayName(call.remoteAddress)
)
callsTopBarStatus.postValue(LinphoneUtils.callStateToString(call.state))
}
Log.i(
"$TAG No more than one call, asking activity to change status bar color back to primary"
)

View file

@ -41,6 +41,9 @@ import org.linphone.core.AudioDevice
import org.linphone.core.Call
import org.linphone.core.CallListenerStub
import org.linphone.core.CallStats
import org.linphone.core.ChatRoom
import org.linphone.core.ChatRoomListenerStub
import org.linphone.core.ChatRoomParams
import org.linphone.core.Core
import org.linphone.core.CoreListenerStub
import org.linphone.core.MediaDirection
@ -54,6 +57,7 @@ import org.linphone.ui.call.model.CallStatsModel
import org.linphone.ui.call.model.ConferenceModel
import org.linphone.ui.main.contacts.model.ContactAvatarModel
import org.linphone.ui.main.history.model.NumpadModel
import org.linphone.ui.main.model.isInSecureMode
import org.linphone.utils.AppUtils
import org.linphone.utils.AudioUtils
import org.linphone.utils.Event
@ -155,6 +159,18 @@ class CurrentCallViewModel @UiThread constructor() : ViewModel() {
MutableLiveData<Event<Pair<String, String>>>()
}
// Chat
val operationInProgress = MutableLiveData<Boolean>()
val goToConversationEvent: MutableLiveData<Event<Pair<String, String>>> by lazy {
MutableLiveData<Event<Pair<String, String>>>()
}
val chatRoomCreationErrorEvent: MutableLiveData<Event<String>> by lazy {
MutableLiveData<Event<String>>()
}
// Conference
val conferenceModel = ConferenceModel()
@ -284,6 +300,34 @@ class CurrentCallViewModel @UiThread constructor() : ViewModel() {
}
}
private val chatRoomListener = object : ChatRoomListenerStub() {
@WorkerThread
override fun onStateChanged(chatRoom: ChatRoom, newState: ChatRoom.State?) {
val state = chatRoom.state
val id = LinphoneUtils.getChatRoomId(chatRoom)
Log.i("$TAG Conversation [$id] (${chatRoom.subject}) state changed: [$state]")
if (state == ChatRoom.State.Created) {
Log.i("$TAG Conversation [$id] successfully created")
chatRoom.removeListener(this)
operationInProgress.postValue(false)
goToConversationEvent.postValue(
Event(
Pair(
chatRoom.localAddress.asStringUriOnly(),
chatRoom.peerAddress.asStringUriOnly()
)
)
)
} else if (state == ChatRoom.State.CreationFailed) {
Log.e("$TAG Conversation [$id] creation has failed!")
chatRoom.removeListener(this)
operationInProgress.postValue(false)
chatRoomCreationErrorEvent.postValue(Event("Error!")) // TODO: use translated string
}
}
}
private val coreListener = object : CoreListenerStub() {
override fun onCallStateChanged(
core: Core,
@ -331,6 +375,7 @@ class CurrentCallViewModel @UiThread constructor() : ViewModel() {
init {
fullScreenMode.value = false
operationInProgress.value = false
coreContext.postOnCoreThread { core ->
core.addListener(coreListener)
@ -669,6 +714,115 @@ class CurrentCallViewModel @UiThread constructor() : ViewModel() {
}
}
@UiThread
fun createConversation() {
coreContext.postOnCoreThread { core ->
val account = core.defaultAccount
val localSipUri = account?.params?.identityAddress?.asStringUriOnly()
val remote = currentCall.remoteAddress
if (!localSipUri.isNullOrEmpty()) {
val remoteSipUri = remote.asStringUriOnly()
Log.i(
"$TAG Looking for existing conversation between [$localSipUri] and [$remoteSipUri]"
)
val params: ChatRoomParams = coreContext.core.createDefaultChatRoomParams()
params.isGroupEnabled = false
params.subject = AppUtils.getString(R.string.conversation_one_to_one_hidden_subject)
params.ephemeralLifetime = 0 // Make sure ephemeral is disabled by default
val sameDomain =
remote.domain == corePreferences.defaultDomain && remote.domain == account.params.domain
if (account.isInSecureMode() && sameDomain) {
Log.i(
"$TAG Account is in secure mode & domain matches, creating a E2E conversation"
)
params.backend = ChatRoom.Backend.FlexisipChat
params.isEncryptionEnabled = true
} else if (!account.isInSecureMode()) {
if (LinphoneUtils.isEndToEndEncryptedChatAvailable(core)) {
Log.i(
"$TAG Account is in interop mode but LIME is available, creating a E2E conversation"
)
params.backend = ChatRoom.Backend.FlexisipChat
params.isEncryptionEnabled = true
} else {
Log.i(
"$TAG Account is in interop mode but LIME isn't available, creating a SIP simple conversation"
)
params.backend = ChatRoom.Backend.Basic
params.isEncryptionEnabled = false
}
} else {
Log.e(
"$TAG Account is in secure mode, can't chat with SIP address of different domain [${remote.asStringUriOnly()}]"
)
return@postOnCoreThread
}
val participants = arrayOf(remote)
val localAddress = account.params.identityAddress
val existingChatRoom = core.searchChatRoom(params, localAddress, null, participants)
if (existingChatRoom != null) {
Log.i(
"$TAG Found existing conversation [${
LinphoneUtils.getChatRoomId(
existingChatRoom
)
}], going to it"
)
goToConversationEvent.postValue(
Event(Pair(localSipUri, existingChatRoom.peerAddress.asStringUriOnly()))
)
} else {
Log.i(
"$TAG No existing conversation between [$localSipUri] and [$remoteSipUri] was found, let's create it"
)
operationInProgress.postValue(true)
val chatRoom = core.createChatRoom(params, localAddress, participants)
if (chatRoom != null) {
if (params.backend == ChatRoom.Backend.FlexisipChat) {
if (chatRoom.state == ChatRoom.State.Created) {
val id = LinphoneUtils.getChatRoomId(chatRoom)
Log.i("$TAG 1-1 conversation [$id] has been created")
operationInProgress.postValue(false)
goToConversationEvent.postValue(
Event(
Pair(
chatRoom.localAddress.asStringUriOnly(),
chatRoom.peerAddress.asStringUriOnly()
)
)
)
} else {
Log.i("$TAG Conversation isn't in Created state yet, wait for it")
chatRoom.addListener(chatRoomListener)
}
} else {
val id = LinphoneUtils.getChatRoomId(chatRoom)
Log.i("$TAG Conversation successfully created [$id]")
operationInProgress.postValue(false)
goToConversationEvent.postValue(
Event(
Pair(
chatRoom.localAddress.asStringUriOnly(),
chatRoom.peerAddress.asStringUriOnly()
)
)
)
}
} else {
Log.e(
"$TAG Failed to create 1-1 conversation with [${remote.asStringUriOnly()}]!"
)
operationInProgress.postValue(false)
chatRoomCreationErrorEvent.postValue(Event("Error!")) // TODO: use translated string
}
}
}
}
}
@WorkerThread
fun blindTransferCallTo(to: Address) {
if (::currentCall.isInitialized) {

View file

@ -261,6 +261,7 @@ class ConversationFragment : SlidingPaneChildFragment() {
}
private var currentChatMessageModelForBottomSheet: MessageModel? = null
private val bottomSheetCallback = object : BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) {
if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
@ -805,6 +806,7 @@ class ConversationFragment : SlidingPaneChildFragment() {
if (indexToScrollTo == adapter.itemCount - 1) {
viewModel.isUserScrollingUp.postValue(false)
viewModel.markAsRead()
}
}

View file

@ -57,6 +57,8 @@ class ConversationViewModel @UiThread constructor() : AbstractConversationViewMo
val showBackButton = MutableLiveData<Boolean>()
val isInCallConversation = MutableLiveData<Boolean>()
val avatarModel = MutableLiveData<ContactAvatarModel>()
val isEmpty = MutableLiveData<Boolean>()

View file

@ -83,6 +83,8 @@ class SendMessageInConversationViewModel @UiThread constructor() : ViewModel() {
val isKeyboardOpen = MutableLiveData<Boolean>()
val isInCallConversation = MutableLiveData<Boolean>()
val isVoiceRecording = MutableLiveData<Boolean>()
val isVoiceRecordingInProgress = MutableLiveData<Boolean>()
@ -149,6 +151,7 @@ class SendMessageInConversationViewModel @UiThread constructor() : ViewModel() {
isEmojiPickerOpen.value = false
isPlayingVoiceRecord.value = false
isInCallConversation.value = false
}
override fun onCleared() {

View file

@ -254,7 +254,11 @@ fun ImageView.loadImageForChatBubbleGrid(file: String?) {
}
private fun loadImageForChatBubble(imageView: ImageView, file: String?, grid: Boolean) {
if (!file.isNullOrEmpty()) {
if (file.isNullOrEmpty()) return
val isImage = FileUtils.isExtensionImage((file))
val isVideo = FileUtils.isExtensionVideo(file)
if (isImage || isVideo) {
val dimen = if (grid) {
imageView.resources.getDimension(R.dimen.chat_bubble_grid_image_size).toInt()
} else {
@ -266,7 +270,7 @@ private fun loadImageForChatBubble(imageView: ImageView, file: String?, grid: Bo
R.dimen.chat_bubble_images_rounded_corner_radius
)
if (FileUtils.isExtensionVideo(file)) {
if (isVideo) {
imageView.load(file) {
placeholder(R.drawable.image_square)
videoFrameMillis(0)

View file

@ -1,13 +0,0 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:name="vector"
android:width="28dp"
android:height="28dp"
android:viewportWidth="28"
android:viewportHeight="28">
<path
android:name="path"
android:pathData="M 7.63 5.833 C 7.7 6.86 7.875 7.875 8.143 8.843 L 6.743 10.255 C 6.277 8.843 5.973 7.373 5.868 5.833 L 7.63 5.833 Z M 19.133 19.845 C 20.125 20.125 21.14 20.3 22.167 20.37 L 22.167 22.12 C 20.627 22.015 19.133 21.712 17.733 21.233 L 19.133 19.845 Z M 8.75 3.5 L 4.667 3.5 C 4.025 3.5 3.5 4.025 3.5 4.667 C 3.5 15.622 12.378 24.5 23.333 24.5 C 23.975 24.5 24.5 23.975 24.5 23.333 L 24.5 19.25 C 24.5 18.608 23.975 18.083 23.333 18.083 C 21.875 18.083 20.475 17.85 19.168 17.418 C 19.052 17.383 18.923 17.36 18.807 17.36 C 18.503 17.36 18.212 17.477 17.978 17.698 L 15.412 20.265 C 12.11 18.585 9.403 15.89 7.723 12.577 L 10.29 9.998 C 10.617 9.695 10.71 9.24 10.582 8.832 C 10.15 7.525 9.917 6.125 9.917 4.667 C 9.917 4.025 9.392 3.5 8.75 3.5 Z M 17.5 3.5 L 19.833 3.5 L 19.833 11.667 L 17.5 11.667 L 17.5 3.5 Z M 22.167 3.5 L 24.5 3.5 L 24.5 11.667 L 22.167 11.667 L 22.167 3.5 Z"
android:fillColor="#c0d1d9"
android:strokeWidth="1"/>
</vector>

View file

@ -118,17 +118,32 @@
<ImageView
android:id="@+id/chat"
android:onClick="@{() -> viewModel.createConversation()}"
android:layout_width="0dp"
android:layout_height="@dimen/call_button_size"
android:layout_marginTop="@dimen/call_extra_button_top_margin"
android:background="@drawable/shape_round_in_call_disabled_button_background"
android:background="@drawable/in_call_button_background_red"
android:padding="@dimen/call_button_icon_padding"
android:src="@drawable/chat_teardrop_text"
android:enabled="@{!viewModel.operationInProgress}"
app:layout_constraintDimensionRatio="1:1"
app:layout_constraintEnd_toEndOf="@id/chat_label"
app:layout_constraintStart_toStartOf="@id/chat_label"
app:layout_constraintTop_toBottomOf="@id/main_actions"
app:tint="@color/gray_500" />
app:tint="@color/in_call_button_tint_color" />
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/chat_room_creation_in_progress"
android:layout_width="0dp"
android:layout_height="0dp"
android:indeterminate="true"
android:visibility="@{viewModel.operationInProgress ? View.VISIBLE : View.GONE}"
app:indicatorColor="@color/main1_500"
app:layout_constraintDimensionRatio="1:1"
app:layout_constraintTop_toTopOf="@id/chat"
app:layout_constraintStart_toStartOf="@id/chat"
app:layout_constraintEnd_toEndOf="@id/chat"
app:layout_constraintBottom_toBottomOf="@id/chat"/>
<ImageView
android:id="@+id/pause_call"
@ -216,11 +231,12 @@
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/chat_label"
style="@style/in_call_extra_action_label_style"
android:onClick="@{() -> viewModel.createConversation()}"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:paddingBottom="15dp"
android:text="@string/call_action_show_messages"
android:enabled="false"
android:enabled="@{!viewModel.operationInProgress}"
app:layout_constraintEnd_toStartOf="@id/pause_call_label"
app:layout_constraintStart_toEndOf="@id/numpad_label"
app:layout_constraintTop_toBottomOf="@id/chat" />

View file

@ -118,18 +118,33 @@
<ImageView
android:id="@+id/chat"
android:onClick="@{() -> viewModel.createConversation()}"
android:layout_width="0dp"
android:layout_height="@dimen/call_button_size"
android:layout_marginTop="@dimen/call_extra_button_top_margin"
android:padding="@dimen/call_button_icon_padding"
android:src="@drawable/chat_teardrop_text"
android:background="@drawable/shape_round_in_call_disabled_button_background"
app:tint="?attr/color_grey_500"
android:background="@drawable/in_call_button_background_red"
android:enabled="@{!viewModel.operationInProgress}"
app:tint="@color/in_call_button_tint_color"
app:layout_constraintDimensionRatio="1:1"
app:layout_constraintTop_toBottomOf="@id/transfer_label"
app:layout_constraintStart_toStartOf="@id/transfer"
app:layout_constraintEnd_toEndOf="@id/transfer"/>
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/chat_room_creation_in_progress"
android:layout_width="0dp"
android:layout_height="0dp"
android:indeterminate="true"
android:visibility="@{viewModel.operationInProgress ? View.VISIBLE : View.GONE}"
app:indicatorColor="@color/main1_500"
app:layout_constraintDimensionRatio="1:1"
app:layout_constraintTop_toTopOf="@id/chat"
app:layout_constraintStart_toStartOf="@id/chat"
app:layout_constraintEnd_toEndOf="@id/chat"
app:layout_constraintBottom_toBottomOf="@id/chat"/>
<ImageView
android:id="@+id/pause_call"
android:onClick="@{() -> viewModel.togglePause()}"
@ -212,11 +227,12 @@
<androidx.appcompat.widget.AppCompatTextView
style="@style/in_call_extra_action_label_style"
android:id="@+id/chat_label"
android:onClick="@{() -> viewModel.createConversation()}"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:paddingBottom="15dp"
android:text="@string/call_action_show_messages"
android:enabled="false"
android:enabled="@{!viewModel.operationInProgress}"
app:layout_constraintTop_toBottomOf="@id/chat"
app:layout_constraintStart_toStartOf="@id/transfer_label"
app:layout_constraintEnd_toEndOf="@id/transfer_label" />

View file

@ -28,7 +28,7 @@
layout="@layout/call_activity_other_calls_top_bar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:visibility="@{callsViewModel.callsCount >= 2 ? View.VISIBLE : View.GONE, default=gone}"
android:visibility="@{callsViewModel.callsCount > 1 || callsViewModel.showTopBar ? View.VISIBLE : View.GONE, default=gone}"
app:viewModel="@{callsViewModel}"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"

View file

@ -14,7 +14,19 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/color_success_500"
android:onClick="@{() -> viewModel.goToCallsList()}">
android:onClick="@{() -> viewModel.topBarClicked()}">
<ImageView
android:id="@+id/call_icon"
android:layout_width="@dimen/icon_size"
android:layout_height="@dimen/icon_size"
android:layout_marginStart="16dp"
android:layout_marginEnd="10dp"
android:src="@{viewModel.callsTopBarIcon, default=@drawable/phone_pause}"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/call_display_name"
app:layout_constraintBottom_toBottomOf="@id/call_display_name"
app:tint="@color/white" />
<androidx.appcompat.widget.AppCompatTextView
style="@style/default_text_style_800"
@ -26,14 +38,11 @@
android:layout_marginTop="5dp"
android:layout_marginBottom="5dp"
android:gravity="center_vertical"
android:text="@{viewModel.otherCallsLabel, default=`John Doe`}"
android:text="@{viewModel.callsTopBarLabel, default=`John Doe`}"
android:textColor="@color/white"
android:textSize="16sp"
android:drawableStart="@drawable/pause_call"
android:drawablePadding="10dp"
android:drawableTint="@color/white"
app:layout_constraintEnd_toStartOf="@id/call_time"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintStart_toEndOf="@id/call_icon"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
@ -44,7 +53,7 @@
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:gravity="center_vertical"
android:text="@{viewModel.otherCallsStatus, default=`Paused`}"
android:text="@{viewModel.callsTopBarStatus, default=`Paused`}"
android:textColor="@color/white"
android:textSize="14sp"
app:layout_constraintEnd_toEndOf="parent"

View file

@ -55,7 +55,7 @@
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="5dp"
android:src="@{model.isPaused ? @drawable/pause_call : @drawable/phone_call, default=@drawable/pause_call}"
android:src="@{model.isPaused ? @drawable/phone_pause : @drawable/phone_call, default=@drawable/phone_pause}"
app:tint="?attr/color_main2_500"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"

View file

@ -30,7 +30,7 @@
style="@style/context_menu_action_label_style"
android:background="@drawable/menu_item_background"
android:layout_marginBottom="1dp"
android:drawableStart="@drawable/pause_call"
android:drawableStart="@drawable/phone_pause"
app:layout_constraintBottom_toTopOf="@id/hang_up"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"/>
@ -45,7 +45,7 @@
style="@style/context_menu_action_label_style"
android:background="@drawable/menu_item_background"
android:layout_marginBottom="1dp"
android:drawableStart="@drawable/pause_call"
android:drawableStart="@drawable/phone_pause"
app:layout_constraintBottom_toTopOf="@id/hang_up"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"/>

View file

@ -26,6 +26,9 @@
<variable
name="model"
type="org.linphone.ui.main.chat.model.MessageModel" />
<variable
name="hideForward"
type="Boolean" />
</data>
<androidx.coordinatorlayout.widget.CoordinatorLayout
@ -156,6 +159,7 @@
android:background="@drawable/menu_item_background"
android:layout_marginBottom="1dp"
android:drawableStart="@drawable/forward"
android:visibility="@{hideForward ? View.GONE : View.VISIBLE}"
app:layout_constraintBottom_toTopOf="@id/delete"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"/>

View file

@ -69,7 +69,7 @@
android:padding="15dp"
android:adjustViewBounds="true"
android:onClick="@{backClickListener}"
android:visibility="@{viewModel.showBackButton &amp;&amp; !viewModel.searchBarVisible ? View.VISIBLE : View.GONE}"
android:visibility="@{viewModel.isInCallConversation || viewModel.showBackButton &amp;&amp; !viewModel.searchBarVisible ? View.VISIBLE : View.GONE}"
android:src="@drawable/caret_left"
android:contentDescription="@string/content_description_go_back_icon"
app:tint="?attr/color_main1_500"
@ -148,6 +148,7 @@
android:padding="15dp"
android:adjustViewBounds="true"
android:src="@drawable/dots_three_vertical"
android:visibility="@{viewModel.isInCallConversation ? View.GONE : View.VISIBLE}"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:tint="?attr/color_main2_500"/>
@ -159,7 +160,7 @@
android:layout_height="@dimen/top_bar_height"
android:padding="15dp"
android:src="@drawable/phone"
android:visibility="@{viewModel.isReadOnly || viewModel.searchBarVisible ? View.GONE : View.VISIBLE}"
android:visibility="@{viewModel.isInCallConversation || viewModel.isReadOnly || viewModel.searchBarVisible ? View.GONE : View.VISIBLE}"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toStartOf="@id/show_menu"
app:tint="?attr/color_main2_500" />

View file

@ -33,7 +33,7 @@
android:id="@+id/extra_actions"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="@{viewModel.isVoiceRecording ? View.INVISIBLE : viewModel.isKeyboardOpen ? View.GONE : View.VISIBLE}"
android:visibility="@{viewModel.isVoiceRecording ? View.INVISIBLE : (viewModel.isKeyboardOpen || viewModel.isInCallConversation || !viewModel.isFileTransferServerAvailable) ? View.GONE : View.VISIBLE}"
app:constraint_referenced_ids="attach_file, capture_image" />
<include
@ -98,7 +98,6 @@
android:onClick="@{openFilePickerClickListener}"
android:padding="8dp"
android:src="@drawable/paperclip"
android:visibility="@{viewModel.isFileTransferServerAvailable ? View.VISIBLE : View.GONE}"
app:layout_constraintBottom_toBottomOf="@id/message_area_background"
app:layout_constraintEnd_toStartOf="@id/capture_image"
app:layout_constraintStart_toEndOf="@id/emoji_picker_toggle"
@ -113,7 +112,6 @@
android:onClick="@{openCameraClickListener}"
android:padding="8dp"
android:src="@drawable/camera"
android:visibility="@{viewModel.isFileTransferServerAvailable ? View.VISIBLE : View.GONE}"
app:layout_constraintBottom_toBottomOf="@id/message_area_background"
app:layout_constraintEnd_toStartOf="@id/message_area_background"
app:layout_constraintStart_toEndOf="@id/attach_file"
@ -168,7 +166,8 @@
android:layout_width="40dp"
android:layout_height="0dp"
android:layout_marginEnd="4dp"
android:visibility="@{viewModel.textToSend.length() > 0 || viewModel.attachments.size() > 0 ? View.VISIBLE : View.GONE, default=gone}"
android:enabled="@{viewModel.textToSend.length() > 0 || viewModel.attachments.size() > 0}"
android:visibility="@{viewModel.isInCallConversation || viewModel.textToSend.length() > 0 || viewModel.attachments.size() > 0 ? View.VISIBLE : View.GONE, default=gone}"
android:onClick="@{() -> viewModel.sendMessage()}"
android:padding="8dp"
android:src="@drawable/paper_plane_right"
@ -182,7 +181,7 @@
android:layout_width="40dp"
android:layout_height="0dp"
android:layout_marginEnd="4dp"
android:visibility="@{viewModel.textToSend.length() > 0 || viewModel.attachments.size() > 0 || viewModel.isVoiceRecording || !viewModel.isFileTransferServerAvailable ? View.GONE : View.VISIBLE}"
android:visibility="@{viewModel.isInCallConversation || viewModel.textToSend.length() > 0 || viewModel.attachments.size() > 0 || viewModel.isVoiceRecording || !viewModel.isFileTransferServerAvailable ? View.GONE : View.VISIBLE}"
android:onClick="@{() -> viewModel.startVoiceMessageRecording()}"
android:padding="8dp"
android:src="@drawable/microphone"

View file

@ -84,6 +84,12 @@
app:popUpTo="@id/activeCallFragment"
app:popUpToInclusive="true"
app:launchSingleTop="true" />
<action
android:id="@+id/action_activeCallFragment_to_inCallConversationFragment"
app:destination="@id/inCallConversationFragment"
app:enterAnim="@anim/slide_in"
app:popExitAnim="@anim/slide_out"
app:launchSingleTop="true" />
</fragment>
<action android:id="@+id/action_global_activeCallFragment"
@ -159,4 +165,17 @@
android:label="ConferenceParticipantsListFragment"
tools:layout="@layout/call_conference_participants_list_fragment"/>
<fragment
android:id="@+id/inCallConversationFragment"
android:name="org.linphone.ui.call.fragment.ConversationFragment"
android:label="ConversationFragment"
tools:layout="@layout/chat_conversation_fragment">
<argument
android:name="localSipUri"
app:argType="string" />
<argument
android:name="remoteSipUri"
app:argType="string" />
</fragment>
</navigation>