diff --git a/app/src/main/java/org/linphone/ui/call/CallActivity.kt b/app/src/main/java/org/linphone/ui/call/CallActivity.kt index 80a4749f0..e436f810a 100644 --- a/app/src/main/java/org/linphone/ui/call/CallActivity.kt +++ b/app/src/main/java/org/linphone/ui/call/CallActivity.kt @@ -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, diff --git a/app/src/main/java/org/linphone/ui/call/fragment/ActiveCallFragment.kt b/app/src/main/java/org/linphone/ui/call/fragment/ActiveCallFragment.kt index eca76502b..f992ee69d 100644 --- a/app/src/main/java/org/linphone/ui/call/fragment/ActiveCallFragment.kt +++ b/app/src/main/java/org/linphone/ui/call/fragment/ActiveCallFragment.kt @@ -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") diff --git a/app/src/main/java/org/linphone/ui/call/fragment/CallsListFragment.kt b/app/src/main/java/org/linphone/ui/call/fragment/CallsListFragment.kt index fdec459db..4edac46e2 100644 --- a/app/src/main/java/org/linphone/ui/call/fragment/CallsListFragment.kt +++ b/app/src/main/java/org/linphone/ui/call/fragment/CallsListFragment.kt @@ -99,7 +99,7 @@ class CallsListFragment : GenericCallFragment() { } binding.setMergeCallsClickListener { - viewModel.mergeCallsIntoLocalConference() + viewModel.mergeCallsIntoConference() } viewModel.calls.observe(viewLifecycleOwner) { diff --git a/app/src/main/java/org/linphone/ui/call/fragment/ConversationFragment.kt b/app/src/main/java/org/linphone/ui/call/fragment/ConversationFragment.kt new file mode 100644 index 000000000..59f70b25e --- /dev/null +++ b/app/src/main/java/org/linphone/ui/call/fragment/ConversationFragment.kt @@ -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 . + */ +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 + } +} diff --git a/app/src/main/java/org/linphone/ui/call/viewmodel/CallsViewModel.kt b/app/src/main/java/org/linphone/ui/call/viewmodel/CallsViewModel.kt index 0fdbefdcf..774de120c 100644 --- a/app/src/main/java/org/linphone/ui/call/viewmodel/CallsViewModel.kt +++ b/app/src/main/java/org/linphone/ui/call/viewmodel/CallsViewModel.kt @@ -43,6 +43,8 @@ class CallsViewModel @UiThread constructor() : ViewModel() { val callsCount = MutableLiveData() + val showTopBar = MutableLiveData() + val goToActiveCallEvent = MutableLiveData>() val showIncomingCallEvent = MutableLiveData>() @@ -51,9 +53,11 @@ class CallsViewModel @UiThread constructor() : ViewModel() { val noCallFoundEvent = MutableLiveData>() - val otherCallsLabel = MutableLiveData() + val callsTopBarLabel = MutableLiveData() - val otherCallsStatus = MutableLiveData() + val callsTopBarIcon = MutableLiveData() + + val callsTopBarStatus = MutableLiveData() val goToCallsListEvent: MutableLiveData> by lazy { MutableLiveData>() @@ -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" ) diff --git a/app/src/main/java/org/linphone/ui/call/viewmodel/CurrentCallViewModel.kt b/app/src/main/java/org/linphone/ui/call/viewmodel/CurrentCallViewModel.kt index 33d406e67..8892964cb 100644 --- a/app/src/main/java/org/linphone/ui/call/viewmodel/CurrentCallViewModel.kt +++ b/app/src/main/java/org/linphone/ui/call/viewmodel/CurrentCallViewModel.kt @@ -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>>() } + // Chat + + val operationInProgress = MutableLiveData() + + val goToConversationEvent: MutableLiveData>> by lazy { + MutableLiveData>>() + } + + val chatRoomCreationErrorEvent: MutableLiveData> by lazy { + MutableLiveData>() + } + // 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) { diff --git a/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationFragment.kt b/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationFragment.kt index 21faecaf3..f3aeaf88d 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationFragment.kt @@ -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() } } diff --git a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationViewModel.kt b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationViewModel.kt index 0c6a74023..e62d70d4d 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationViewModel.kt @@ -57,6 +57,8 @@ class ConversationViewModel @UiThread constructor() : AbstractConversationViewMo val showBackButton = MutableLiveData() + val isInCallConversation = MutableLiveData() + val avatarModel = MutableLiveData() val isEmpty = MutableLiveData() diff --git a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/SendMessageInConversationViewModel.kt b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/SendMessageInConversationViewModel.kt index defc6019b..7367395e0 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/SendMessageInConversationViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/SendMessageInConversationViewModel.kt @@ -83,6 +83,8 @@ class SendMessageInConversationViewModel @UiThread constructor() : ViewModel() { val isKeyboardOpen = MutableLiveData() + val isInCallConversation = MutableLiveData() + val isVoiceRecording = MutableLiveData() val isVoiceRecordingInProgress = MutableLiveData() @@ -149,6 +151,7 @@ class SendMessageInConversationViewModel @UiThread constructor() : ViewModel() { isEmojiPickerOpen.value = false isPlayingVoiceRecord.value = false + isInCallConversation.value = false } override fun onCleared() { diff --git a/app/src/main/java/org/linphone/utils/DataBindingUtils.kt b/app/src/main/java/org/linphone/utils/DataBindingUtils.kt index 6b7239e86..49cdf1ec9 100644 --- a/app/src/main/java/org/linphone/utils/DataBindingUtils.kt +++ b/app/src/main/java/org/linphone/utils/DataBindingUtils.kt @@ -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) diff --git a/app/src/main/res/drawable/pause_call.xml b/app/src/main/res/drawable/pause_call.xml deleted file mode 100644 index b380c2569..000000000 --- a/app/src/main/res/drawable/pause_call.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - diff --git a/app/src/main/res/layout-land/call_actions_bottom_sheet.xml b/app/src/main/res/layout-land/call_actions_bottom_sheet.xml index da98ae2dc..bb0d85459 100644 --- a/app/src/main/res/layout-land/call_actions_bottom_sheet.xml +++ b/app/src/main/res/layout-land/call_actions_bottom_sheet.xml @@ -118,17 +118,32 @@ + app:tint="@color/in_call_button_tint_color" /> + + diff --git a/app/src/main/res/layout/call_actions_bottom_sheet.xml b/app/src/main/res/layout/call_actions_bottom_sheet.xml index c9c593ffe..3f5536784 100644 --- a/app/src/main/res/layout/call_actions_bottom_sheet.xml +++ b/app/src/main/res/layout/call_actions_bottom_sheet.xml @@ -118,18 +118,33 @@ + + diff --git a/app/src/main/res/layout/call_activity.xml b/app/src/main/res/layout/call_activity.xml index 772c7ad6c..53e2fe6a6 100644 --- a/app/src/main/res/layout/call_activity.xml +++ b/app/src/main/res/layout/call_activity.xml @@ -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" diff --git a/app/src/main/res/layout/call_activity_other_calls_top_bar.xml b/app/src/main/res/layout/call_activity_other_calls_top_bar.xml index d23af3eda..a25e07a18 100644 --- a/app/src/main/res/layout/call_activity_other_calls_top_bar.xml +++ b/app/src/main/res/layout/call_activity_other_calls_top_bar.xml @@ -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()}"> + + @@ -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" diff --git a/app/src/main/res/layout/call_list_cell.xml b/app/src/main/res/layout/call_list_cell.xml index 55c00f10a..20a108db5 100644 --- a/app/src/main/res/layout/call_list_cell.xml +++ b/app/src/main/res/layout/call_list_cell.xml @@ -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" diff --git a/app/src/main/res/layout/calls_list_long_press_menu.xml b/app/src/main/res/layout/calls_list_long_press_menu.xml index 6c03a7d58..c28cd5ad6 100644 --- a/app/src/main/res/layout/calls_list_long_press_menu.xml +++ b/app/src/main/res/layout/calls_list_long_press_menu.xml @@ -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"/> diff --git a/app/src/main/res/layout/chat_bubble_long_press_menu.xml b/app/src/main/res/layout/chat_bubble_long_press_menu.xml index 5e5ef02cd..1ee1d32f1 100644 --- a/app/src/main/res/layout/chat_bubble_long_press_menu.xml +++ b/app/src/main/res/layout/chat_bubble_long_press_menu.xml @@ -26,6 +26,9 @@ + diff --git a/app/src/main/res/layout/chat_conversation_fragment.xml b/app/src/main/res/layout/chat_conversation_fragment.xml index ef3951b9a..198a34b27 100644 --- a/app/src/main/res/layout/chat_conversation_fragment.xml +++ b/app/src/main/res/layout/chat_conversation_fragment.xml @@ -69,7 +69,7 @@ android:padding="15dp" android:adjustViewBounds="true" android:onClick="@{backClickListener}" - android:visibility="@{viewModel.showBackButton && !viewModel.searchBarVisible ? View.VISIBLE : View.GONE}" + android:visibility="@{viewModel.isInCallConversation || viewModel.showBackButton && !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" /> diff --git a/app/src/main/res/layout/chat_conversation_send_area.xml b/app/src/main/res/layout/chat_conversation_send_area.xml index 50fe20af6..fcdc5b2e4 100644 --- a/app/src/main/res/layout/chat_conversation_send_area.xml +++ b/app/src/main/res/layout/chat_conversation_send_area.xml @@ -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" /> + + + + + + \ No newline at end of file