From 3810ab4ae97877c652ef112a284ef6ecd47b812f Mon Sep 17 00:00:00 2001 From: Sylvain Berfini Date: Mon, 5 Feb 2024 16:12:42 +0100 Subject: [PATCH] Added medias list in conversation --- .../java/org/linphone/ui/main/MainActivity.kt | 100 +++++--- .../ui/main/chat/adapter/MediaListAdapter.kt | 49 ++++ .../chat/fragment/ConversationFragment.kt | 71 +++++- .../fragment/ConversationMediaListFragment.kt | 160 +++++++++++++ .../fragment/ConversationsListFragment.kt | 42 +--- .../chat/fragment/MediaListViewerFragment.kt | 225 ++++++++++++++++++ .../main/chat/fragment/MediaViewerFragment.kt | 118 +++++++++ .../ConversationMediaListViewModel.kt | 121 ++++++++++ .../ui/main/chat/viewmodel/MediaViewModel.kt | 67 ++++++ .../viewer/fragment/FileViewerFragment.kt | 15 +- .../ui/main/viewmodel/SharedMainViewModel.kt | 6 +- app/src/main/res/drawable/image.xml | 9 + .../layout/chat_conversation_popup_menu.xml | 35 ++- .../layout/chat_media_content_grid_cell.xml | 58 +++++ .../main/res/layout/chat_media_fragment.xml | 85 +++++++ .../chat_media_viewer_child_fragment.xml | 56 +++++ .../res/layout/chat_media_viewer_fragment.xml | 111 +++++++++ .../main/res/navigation/chat_nav_graph.xml | 70 ++++++ .../main/res/navigation/main_nav_graph.xml | 24 -- app/src/main/res/values/strings.xml | 6 +- 20 files changed, 1320 insertions(+), 108 deletions(-) create mode 100644 app/src/main/java/org/linphone/ui/main/chat/adapter/MediaListAdapter.kt create mode 100644 app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationMediaListFragment.kt create mode 100644 app/src/main/java/org/linphone/ui/main/chat/fragment/MediaListViewerFragment.kt create mode 100644 app/src/main/java/org/linphone/ui/main/chat/fragment/MediaViewerFragment.kt create mode 100644 app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationMediaListViewModel.kt create mode 100644 app/src/main/java/org/linphone/ui/main/chat/viewmodel/MediaViewModel.kt create mode 100644 app/src/main/res/drawable/image.xml create mode 100644 app/src/main/res/layout/chat_media_content_grid_cell.xml create mode 100644 app/src/main/res/layout/chat_media_fragment.xml create mode 100644 app/src/main/res/layout/chat_media_viewer_child_fragment.xml create mode 100644 app/src/main/res/layout/chat_media_viewer_fragment.xml diff --git a/app/src/main/java/org/linphone/ui/main/MainActivity.kt b/app/src/main/java/org/linphone/ui/main/MainActivity.kt index e5cde22c9..53701b5c7 100644 --- a/app/src/main/java/org/linphone/ui/main/MainActivity.kt +++ b/app/src/main/java/org/linphone/ui/main/MainActivity.kt @@ -42,9 +42,11 @@ import androidx.navigation.NavController import androidx.navigation.NavOptions import androidx.navigation.findNavController import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.LinphoneApplication.Companion.corePreferences import org.linphone.R @@ -265,33 +267,60 @@ class MainActivity : GenericActivity() { } fun showGreenToast(message: String, @DrawableRes icon: Int) { - val greenToast = ToastUtils.getGreenToast(this, binding.toastsArea, message, icon) - binding.toastsArea.addView(greenToast.root) + lifecycleScope.launch { + withContext(Dispatchers.Main) { + val greenToast = ToastUtils.getGreenToast( + this@MainActivity, + binding.toastsArea, + message, + icon + ) + binding.toastsArea.addView(greenToast.root) - greenToast.root.slideInToastFromTopForDuration( - binding.toastsArea as ViewGroup, - lifecycleScope - ) + greenToast.root.slideInToastFromTopForDuration( + binding.toastsArea as ViewGroup, + lifecycleScope + ) + } + } } fun showBlueToast(message: String, @DrawableRes icon: Int) { - val blueToast = ToastUtils.getBlueToast(this, binding.toastsArea, message, icon) - binding.toastsArea.addView(blueToast.root) + lifecycleScope.launch { + withContext(Dispatchers.Main) { + val blueToast = ToastUtils.getBlueToast( + this@MainActivity, + binding.toastsArea, + message, + icon + ) + binding.toastsArea.addView(blueToast.root) - blueToast.root.slideInToastFromTopForDuration( - binding.toastsArea as ViewGroup, - lifecycleScope - ) + blueToast.root.slideInToastFromTopForDuration( + binding.toastsArea as ViewGroup, + lifecycleScope + ) + } + } } fun showRedToast(message: String, @DrawableRes icon: Int) { - val redToast = ToastUtils.getRedToast(this, binding.toastsArea, message, icon) - binding.toastsArea.addView(redToast.root) + lifecycleScope.launch { + withContext(Dispatchers.Main) { + val redToast = ToastUtils.getRedToast( + this@MainActivity, + binding.toastsArea, + message, + icon + ) + binding.toastsArea.addView(redToast.root) - redToast.root.slideInToastFromTopForDuration( - binding.toastsArea as ViewGroup, - lifecycleScope - ) + redToast.root.slideInToastFromTopForDuration( + binding.toastsArea as ViewGroup, + lifecycleScope + ) + } + } } private fun showPersistentRedToast( @@ -300,20 +329,35 @@ class MainActivity : GenericActivity() { tag: String, doNotTint: Boolean = false ) { - val redToast = ToastUtils.getRedToast(this, binding.toastsArea, message, icon, doNotTint) - redToast.root.tag = tag - binding.toastsArea.addView(redToast.root) + lifecycleScope.launch { + withContext(Dispatchers.Main) { + val redToast = + ToastUtils.getRedToast( + this@MainActivity, + binding.toastsArea, + message, + icon, + doNotTint + ) + redToast.root.tag = tag + binding.toastsArea.addView(redToast.root) - redToast.root.slideInToastFromTop( - binding.toastsArea as ViewGroup, - true - ) + redToast.root.slideInToastFromTop( + binding.toastsArea as ViewGroup, + true + ) + } + } } private fun removePersistentRedToast(tag: String) { - for (child in binding.toastsArea.children) { - if (child.tag == tag) { - binding.toastsArea.removeView(child) + lifecycleScope.launch { + withContext(Dispatchers.Main) { + for (child in binding.toastsArea.children) { + if (child.tag == tag) { + binding.toastsArea.removeView(child) + } + } } } } diff --git a/app/src/main/java/org/linphone/ui/main/chat/adapter/MediaListAdapter.kt b/app/src/main/java/org/linphone/ui/main/chat/adapter/MediaListAdapter.kt new file mode 100644 index 000000000..3c8ae7ff2 --- /dev/null +++ b/app/src/main/java/org/linphone/ui/main/chat/adapter/MediaListAdapter.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2010-2023 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.main.chat.adapter + +import android.os.Bundle +import androidx.fragment.app.Fragment +import androidx.viewpager2.adapter.FragmentStateAdapter +import org.linphone.core.tools.Log +import org.linphone.ui.main.chat.fragment.MediaViewerFragment +import org.linphone.ui.main.chat.viewmodel.ConversationMediaListViewModel + +class MediaListAdapter(fragment: Fragment, private val viewModel: ConversationMediaListViewModel) : FragmentStateAdapter( + fragment +) { + companion object { + private const val TAG = "[Media List Adapter]" + } + + override fun getItemCount(): Int { + return viewModel.mediaList.value.orEmpty().size + } + + override fun createFragment(position: Int): Fragment { + val fragment = MediaViewerFragment() + fragment.arguments = Bundle().apply { + val path = viewModel.mediaList.value.orEmpty().getOrNull(position)?.file + Log.i("$TAG Path is [$path] for position [$position]") + putString("path", path) + } + return fragment + } +} 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 16b693c54..c7bb26971 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 @@ -21,6 +21,7 @@ package org.linphone.ui.main.chat.fragment import android.Manifest import android.app.Dialog +import android.content.ActivityNotFoundException import android.content.ClipData import android.content.ClipboardManager import android.content.Context @@ -38,6 +39,8 @@ import android.view.View import android.view.ViewGroup import android.view.Window import android.view.WindowManager +import android.view.animation.Animation +import android.view.animation.AnimationUtils import android.widget.PopupWindow import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts @@ -239,6 +242,17 @@ class ConversationFragment : SlidingPaneChildFragment() { private var bottomSheetReactionsModel: MessageReactionsModel? = null + override fun onCreateAnimation(transit: Int, enter: Boolean, nextAnim: Int): Animation? { + if ( + findNavController().currentDestination?.id == R.id.fileViewerFragment || + findNavController().currentDestination?.id == R.id.mediaListViewerFragment + ) { + // Holds fragment in place while new fragment slides over it + return AnimationUtils.loadAnimation(activity, R.anim.hold) + } + return super.onCreateAnimation(transit, enter, nextAnim) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -505,7 +519,7 @@ class ConversationFragment : SlidingPaneChildFragment() { viewModel.fileToDisplayEvent.observe(viewLifecycleOwner) { it.consume { file -> Log.i("$TAG User clicked on file [$file], let's display it in file viewer") - sharedViewModel.displayFileEvent.value = Event(file) + goToFileViewer(file) } } @@ -729,6 +743,49 @@ class ConversationFragment : SlidingPaneChildFragment() { } } + private fun goToFileViewer(path: String) { + if (findNavController().currentDestination?.id == R.id.conversationFragment) { + Log.i("$TAG Navigating to file viewer fragment with path [$path]") + val extension = FileUtils.getExtensionFromFileName(path) + val mime = FileUtils.getMimeTypeFromExtension(extension) + when (FileUtils.getMimeType(mime)) { + FileUtils.MimeType.Image, FileUtils.MimeType.Video -> { + val action = + ConversationFragmentDirections.actionConversationFragmentToMediaListViewerFragment( + localSipUri = viewModel.localSipUri, + remoteSipUri = viewModel.remoteSipUri, + path = path + ) + findNavController().navigate(action) + } + FileUtils.MimeType.Pdf, FileUtils.MimeType.PlainText -> { + val action = + ConversationFragmentDirections.actionConversationFragmentToFileViewerFragment( + path + ) + findNavController().navigate(action) + } + else -> { + val intent = Intent(Intent.ACTION_VIEW) + val contentUri: Uri = + FileUtils.getPublicFilePath(requireContext(), path) + intent.setDataAndType(contentUri, "file/$mime") + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + try { + requireContext().startActivity(intent) + } catch (anfe: ActivityNotFoundException) { + Log.e("$TAG Can't open file [$path] in third party app: $anfe") + val message = getString( + R.string.toast_no_app_registered_to_handle_content_type_error + ) + val icon = R.drawable.file + (requireActivity() as MainActivity).showRedToast(message, icon) + } + } + } + } + } + private fun showPopupMenu(view: View) { val popupView: ChatConversationPopupMenuBinding = DataBindingUtil.inflate( LayoutInflater.from(requireContext()), @@ -776,6 +833,18 @@ class ConversationFragment : SlidingPaneChildFragment() { popupWindow.dismiss() } + popupView.setMediasClickListener { + if (findNavController().currentDestination?.id == R.id.conversationFragment) { + val action = + ConversationFragmentDirections.actionConversationFragmentToConversationMediaListFragment( + localSipUri = viewModel.localSipUri, + remoteSipUri = viewModel.remoteSipUri + ) + findNavController().navigate(action) + } + popupWindow.dismiss() + } + // Elevation is for showing a shadow around the popup popupWindow.elevation = 20f popupWindow.showAsDropDown(view, 0, 0, Gravity.BOTTOM) diff --git a/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationMediaListFragment.kt b/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationMediaListFragment.kt new file mode 100644 index 000000000..d52cc09a3 --- /dev/null +++ b/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationMediaListFragment.kt @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2010-2023 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.main.chat.fragment + +import android.content.ActivityNotFoundException +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.animation.Animation +import android.view.animation.AnimationUtils +import androidx.annotation.UiThread +import androidx.core.view.doOnPreDraw +import androidx.lifecycle.ViewModelProvider +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import org.linphone.R +import org.linphone.core.tools.Log +import org.linphone.databinding.ChatMediaFragmentBinding +import org.linphone.ui.main.MainActivity +import org.linphone.ui.main.chat.viewmodel.ConversationMediaListViewModel +import org.linphone.ui.main.fragment.SlidingPaneChildFragment +import org.linphone.utils.FileUtils + +@UiThread +class ConversationMediaListFragment : SlidingPaneChildFragment() { + companion object { + private const val TAG = "[Conversation Media List Fragment]" + } + + private lateinit var binding: ChatMediaFragmentBinding + + private lateinit var viewModel: ConversationMediaListViewModel + + private val args: ConversationMediaListFragmentArgs by navArgs() + + override fun onCreateAnimation(transit: Int, enter: Boolean, nextAnim: Int): Animation? { + if ( + findNavController().currentDestination?.id == R.id.mediaListViewerFragment + ) { + // Holds fragment in place while new fragment slides over it + return AnimationUtils.loadAnimation(activity, R.anim.hold) + } + return super.onCreateAnimation(transit, enter, nextAnim) + } + + override fun goBack(): Boolean { + return findNavController().popBackStack() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = ChatMediaFragmentBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + postponeEnterTransition() + super.onViewCreated(view, savedInstanceState) + + binding.lifecycleOwner = viewLifecycleOwner + + viewModel = ViewModelProvider(this)[ConversationMediaListViewModel::class.java] + binding.viewModel = viewModel + + 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]" + ) + val chatRoom = sharedViewModel.displayedChatRoom + viewModel.findChatRoom(chatRoom, localSipUri, remoteSipUri) + + binding.setBackClickListener { + goBack() + } + + viewModel.mediaList.observe(viewLifecycleOwner) { + val count = it.size + Log.i( + "$TAG Found [$count] media for conversation with local SIP URI [$localSipUri] and remote SIP URI [$remoteSipUri]" + ) + (view.parent as? ViewGroup)?.doOnPreDraw { + startPostponedEnterTransition() + } + } + + viewModel.openMediaEvent.observe(viewLifecycleOwner) { + it.consume { model -> + Log.i("$TAG User clicked on file [${model.file}], let's display it in file viewer") + goToFileViewer(model.file) + } + } + } + + private fun goToFileViewer(path: String) { + if (findNavController().currentDestination?.id == R.id.conversationMediaListFragment) { + Log.i("$TAG Navigating to file viewer fragment with path [$path]") + val extension = FileUtils.getExtensionFromFileName(path) + val mime = FileUtils.getMimeTypeFromExtension(extension) + when (FileUtils.getMimeType(mime)) { + FileUtils.MimeType.Image, FileUtils.MimeType.Video -> { + val action = + ConversationMediaListFragmentDirections.actionConversationMediaListFragmentToMediaListViewerFragment( + localSipUri = viewModel.localSipUri, + remoteSipUri = viewModel.remoteSipUri, + path = path + ) + findNavController().navigate(action) + } + FileUtils.MimeType.Pdf, FileUtils.MimeType.PlainText -> { + val action = + ConversationMediaListFragmentDirections.actionConversationMediaListFragmentToFileViewerFragment( + path + ) + findNavController().navigate(action) + } + else -> { + val intent = Intent(Intent.ACTION_VIEW) + val contentUri: Uri = + FileUtils.getPublicFilePath(requireContext(), path) + intent.setDataAndType(contentUri, "file/$mime") + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + try { + requireContext().startActivity(intent) + } catch (anfe: ActivityNotFoundException) { + Log.e("$TAG Can't open file [$path] in third party app: $anfe") + val message = getString( + R.string.toast_no_app_registered_to_handle_content_type_error + ) + val icon = R.drawable.file + (requireActivity() as MainActivity).showRedToast(message, icon) + } + } + } + } + } +} diff --git a/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationsListFragment.kt b/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationsListFragment.kt index 8f05d54fe..1c88b2fda 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationsListFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationsListFragment.kt @@ -19,9 +19,6 @@ */ package org.linphone.ui.main.chat.fragment -import android.content.ActivityNotFoundException -import android.content.Intent -import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -44,10 +41,8 @@ import org.linphone.ui.main.chat.adapter.ConversationsListAdapter import org.linphone.ui.main.chat.viewmodel.ConversationsListViewModel import org.linphone.ui.main.fragment.AbstractTopBarFragment import org.linphone.ui.main.history.fragment.HistoryMenuDialogFragment -import org.linphone.ui.main.viewer.fragment.FileViewerFragmentDirections import org.linphone.utils.AppUtils import org.linphone.utils.Event -import org.linphone.utils.FileUtils @UiThread class ConversationsListFragment : AbstractTopBarFragment() { @@ -91,7 +86,8 @@ class ConversationsListFragment : AbstractTopBarFragment() { if ( findNavController().currentDestination?.id == R.id.startConversationFragment || findNavController().currentDestination?.id == R.id.meetingWaitingRoomFragment || - findNavController().currentDestination?.id == R.id.fileViewerFragment + findNavController().currentDestination?.id == R.id.fileViewerFragment || + findNavController().currentDestination?.id == R.id.mediaListViewerFragment ) { // Holds fragment in place while new fragment slides over it return AnimationUtils.loadAnimation(activity, R.anim.hold) @@ -222,40 +218,6 @@ class ConversationsListFragment : AbstractTopBarFragment() { } } - sharedViewModel.displayFileEvent.observe(viewLifecycleOwner) { - it.consume { path -> - if (findNavController().currentDestination?.id == R.id.conversationsListFragment) { - Log.i("$TAG Navigating to file viewer fragment with path [$path]") - val extension = FileUtils.getExtensionFromFileName(path) - val mime = FileUtils.getMimeTypeFromExtension(extension) - when (FileUtils.getMimeType(mime)) { - FileUtils.MimeType.Image, FileUtils.MimeType.Video, FileUtils.MimeType.Pdf, FileUtils.MimeType.PlainText -> { - val action = - FileViewerFragmentDirections.actionGlobalFileViewerFragment(path) - findNavController().navigate(action) - } - else -> { - val intent = Intent(Intent.ACTION_VIEW) - val contentUri: Uri = - FileUtils.getPublicFilePath(requireContext(), path) - intent.setDataAndType(contentUri, "file/$mime") - intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - try { - requireContext().startActivity(intent) - } catch (anfe: ActivityNotFoundException) { - Log.e("$TAG Can't open file [$path] in third party app: $anfe") - val message = getString( - R.string.toast_no_app_registered_to_handle_content_type_error - ) - val icon = R.drawable.file - (requireActivity() as MainActivity).showRedToast(message, icon) - } - } - } - } - } - } - sharedViewModel.messageToForwardEvent.observe(viewLifecycleOwner) { event -> if (!event.consumed()) { // Do not consume it yet diff --git a/app/src/main/java/org/linphone/ui/main/chat/fragment/MediaListViewerFragment.kt b/app/src/main/java/org/linphone/ui/main/chat/fragment/MediaListViewerFragment.kt new file mode 100644 index 000000000..091ca5809 --- /dev/null +++ b/app/src/main/java/org/linphone/ui/main/chat/fragment/MediaListViewerFragment.kt @@ -0,0 +1,225 @@ +/* + * Copyright (c) 2010-2023 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.main.chat.fragment + +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.doOnPreDraw +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import androidx.viewpager2.widget.ViewPager2 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.linphone.R +import org.linphone.core.tools.Log +import org.linphone.databinding.ChatMediaViewerFragmentBinding +import org.linphone.ui.main.MainActivity +import org.linphone.ui.main.chat.adapter.MediaListAdapter +import org.linphone.ui.main.chat.viewmodel.ConversationMediaListViewModel +import org.linphone.ui.main.fragment.SlidingPaneChildFragment +import org.linphone.utils.AppUtils +import org.linphone.utils.Event +import org.linphone.utils.FileUtils + +class MediaListViewerFragment : SlidingPaneChildFragment() { + companion object { + private const val TAG = "[Media List Viewer]" + } + + private lateinit var binding: ChatMediaViewerFragmentBinding + + private lateinit var adapter: MediaListAdapter + + private lateinit var viewModel: ConversationMediaListViewModel + + private lateinit var viewPager: ViewPager2 + + private val args: MediaListViewerFragmentArgs by navArgs() + + private var navBarDefaultColor: Int = -1 + + private val pageListener = object : ViewPager2.OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + val list = viewModel.mediaList.value.orEmpty() + if (position >= 0 && position < list.size) { + val model = list[position] + viewModel.currentlyDisplayedFileName.value = model.fileName + } + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = ChatMediaViewerFragmentBinding.inflate(layoutInflater) + return binding.root + } + + override fun goBack(): Boolean { + return findNavController().popBackStack() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + postponeEnterTransition() + super.onViewCreated(view, savedInstanceState) + + navBarDefaultColor = requireActivity().window.navigationBarColor + + binding.lifecycleOwner = viewLifecycleOwner + + viewModel = ViewModelProvider(this)[ConversationMediaListViewModel::class.java] + binding.viewModel = viewModel + + // Consider full screen mode the default + sharedViewModel.mediaViewerFullScreenMode.value = true + + val localSipUri = args.localSipUri + val remoteSipUri = args.remoteSipUri + val path = args.path + Log.i( + "$TAG Looking up for conversation with local SIP URI [$localSipUri] and remote SIP URI [$remoteSipUri] trying to display file [$path]" + ) + val chatRoom = sharedViewModel.displayedChatRoom + viewModel.findChatRoom(chatRoom, localSipUri, remoteSipUri) + + viewModel.mediaList.observe(viewLifecycleOwner) { + val count = it.size + Log.i( + "$TAG Found [$count] media for conversation with local SIP URI [$localSipUri] and remote SIP URI [$remoteSipUri]" + ) + + adapter = MediaListAdapter(this, viewModel) + viewPager = binding.mediaViewPager + viewPager.adapter = adapter + + viewPager.registerOnPageChangeCallback(pageListener) + + val index = it.indexOfFirst { model -> + model.file == path + } + Log.i("$TAG Path [$path] is at index [$index]") + val position = if (index == -1) { + count - 1 + } else { + index + } + viewPager.setCurrentItem(position, false) + viewPager.offscreenPageLimit = 2 + + (view.parent as? ViewGroup)?.doOnPreDraw { + startPostponedEnterTransition() + } + } + + binding.setBackClickListener { + goBack() + } + + binding.setShareClickListener { + val list = viewModel.mediaList.value.orEmpty() + val currentItem = binding.mediaViewPager.currentItem + val model = if (currentItem >= 0 && currentItem < list.size) list[currentItem] else null + if (model != null) { + lifecycleScope.launch { + val filePath = FileUtils.getProperFilePath(model.file) + val copy = FileUtils.getFilePath(requireContext(), Uri.parse(filePath), false) + if (!copy.isNullOrEmpty()) { + sharedViewModel.filesToShareFromIntent.value = arrayListOf(copy) + Log.i("$TAG Sharing file [$copy], going back to conversations list") + sharedViewModel.closeSlidingPaneEvent.value = Event(true) + } else { + Log.e("$TAG Failed to copy file [$filePath] to share!") + } + } + } else { + Log.e( + "$TAG Failed to get FileModel at index [$currentItem], only [${list.size}] items in list" + ) + } + } + + binding.setExportClickListener { + val list = viewModel.mediaList.value.orEmpty() + val currentItem = binding.mediaViewPager.currentItem + val model = if (currentItem >= 0 && currentItem < list.size) list[currentItem] else null + if (model != null) { + val filePath = FileUtils.getProperFilePath(model.file) + lifecycleScope.launch { + withContext(Dispatchers.IO) { + Log.i("$TAG Export file [$filePath] to Android's MediaStore") + val mediaStorePath = FileUtils.addContentToMediaStore(filePath) + if (mediaStorePath.isNotEmpty()) { + Log.i( + "$TAG File [$filePath] has been successfully exported to MediaStore" + ) + val message = AppUtils.getString( + R.string.toast_file_successfully_exported_to_media_store + ) + (requireActivity() as MainActivity).showGreenToast( + message, + R.drawable.check + ) + } else { + Log.e("$TAG Failed to export file [$filePath] to MediaStore!") + val message = AppUtils.getString( + R.string.toast_export_file_to_media_store_error + ) + (requireActivity() as MainActivity).showRedToast(message, R.drawable.x) + } + } + } + } else { + Log.e( + "$TAG Failed to get FileModel at index [$currentItem], only [${list.size}] items in list" + ) + } + } + + sharedViewModel.mediaViewerFullScreenMode.observe(viewLifecycleOwner) { + if (it != viewModel.fullScreenMode.value) { + viewModel.fullScreenMode.value = it + } + } + } + + override fun onResume() { + // Force this navigation bar color + requireActivity().window.navigationBarColor = requireContext().getColor(R.color.gray_900) + + super.onResume() + } + + override fun onDestroy() { + // Reset default navigation bar color + requireActivity().window.navigationBarColor = navBarDefaultColor + + viewPager.unregisterOnPageChangeCallback(pageListener) + + super.onDestroy() + } +} diff --git a/app/src/main/java/org/linphone/ui/main/chat/fragment/MediaViewerFragment.kt b/app/src/main/java/org/linphone/ui/main/chat/fragment/MediaViewerFragment.kt new file mode 100644 index 000000000..cb11c406a --- /dev/null +++ b/app/src/main/java/org/linphone/ui/main/chat/fragment/MediaViewerFragment.kt @@ -0,0 +1,118 @@ +package org.linphone.ui.main.chat.fragment + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.UiThread +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProvider +import org.linphone.core.tools.Log +import org.linphone.databinding.ChatMediaViewerChildFragmentBinding +import org.linphone.ui.main.chat.viewmodel.MediaViewModel +import org.linphone.ui.main.viewmodel.SharedMainViewModel + +@UiThread +class MediaViewerFragment : Fragment() { + companion object { + private const val TAG = "[Media Viewer Fragment]" + } + + private lateinit var binding: ChatMediaViewerChildFragmentBinding + + private lateinit var sharedViewModel: SharedMainViewModel + + private lateinit var viewModel: MediaViewModel + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = ChatMediaViewerChildFragmentBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + sharedViewModel = requireActivity().run { + ViewModelProvider(this)[SharedMainViewModel::class.java] + } + + viewModel = ViewModelProvider(this)[MediaViewModel::class.java] + + binding.lifecycleOwner = viewLifecycleOwner + binding.viewModel = viewModel + + val path = if (arguments?.containsKey("path") == true) { + requireArguments().getString("path") + } else { + "" + } + if (path.isNullOrEmpty()) { + Log.e("$TAG Path argument not found!") + return + } + + Log.i("$TAG Path argument is [$path]") + viewModel.loadFile(path) + + viewModel.isVideo.observe(viewLifecycleOwner) { isVideo -> + if (isVideo) { + Log.i("$TAG Creating video player for file [$path]") + binding.videoPlayer.setVideoPath(path) + binding.videoPlayer.setOnCompletionListener { + Log.i("$TAG End of file reached") + viewModel.isVideoPlaying.value = false + } + } + } + + viewModel.toggleVideoPlayPauseEvent.observe(viewLifecycleOwner) { + it.consume { play -> + if (play) { + Log.i("$TAG Starting video player") + binding.videoPlayer.start() + } else { + Log.i("$TAG Pausing video player") + binding.videoPlayer.pause() + } + } + } + + viewModel.fullScreenMode.observe(viewLifecycleOwner) { + if (it != sharedViewModel.mediaViewerFullScreenMode.value) { + sharedViewModel.mediaViewerFullScreenMode.value = it + } + } + } + + override fun onResume() { + super.onResume() + + viewModel.fullScreenMode.value = sharedViewModel.mediaViewerFullScreenMode.value + + if (viewModel.isVideo.value == true) { + Log.i("$TAG Resumed, starting video player") + binding.videoPlayer.start() + viewModel.isVideoPlaying.value = true + } + } + + override fun onPause() { + if (binding.videoPlayer.isPlaying) { + Log.i("$TAG Paused, stopping video player") + binding.videoPlayer.pause() + viewModel.isVideoPlaying.value = false + } + + super.onPause() + } + + override fun onDestroyView() { + binding.videoPlayer.stopPlayback() + + super.onDestroyView() + } +} diff --git a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationMediaListViewModel.kt b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationMediaListViewModel.kt new file mode 100644 index 000000000..042724e0a --- /dev/null +++ b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationMediaListViewModel.kt @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2010-2023 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.main.chat.viewmodel + +import androidx.annotation.UiThread +import androidx.annotation.WorkerThread +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import org.linphone.LinphoneApplication.Companion.coreContext +import org.linphone.core.ChatRoom +import org.linphone.core.Factory +import org.linphone.core.tools.Log +import org.linphone.ui.main.chat.model.FileModel +import org.linphone.utils.Event + +class ConversationMediaListViewModel @UiThread constructor() : ViewModel() { + companion object { + private const val TAG = "[Conversation Media List ViewModel]" + } + + val mediaList = MutableLiveData>() + + val fullScreenMode = MutableLiveData() + + val currentlyDisplayedFileName = MutableLiveData() + + val openMediaEvent: MutableLiveData> by lazy { + MutableLiveData>() + } + + private lateinit var chatRoom: ChatRoom + + lateinit var localSipUri: String + + lateinit var remoteSipUri: String + + @UiThread + fun findChatRoom(room: ChatRoom?, localSipUri: String, remoteSipUri: String) { + this.localSipUri = localSipUri + this.remoteSipUri = remoteSipUri + + coreContext.postOnCoreThread { core -> + Log.i( + "$TAG Looking for conversation with local SIP URI [$localSipUri] and remote SIP URI [$remoteSipUri]" + ) + if (room != null && ::chatRoom.isInitialized && chatRoom == room) { + Log.i("$TAG Conversation object already in memory, skipping") + loadMediaList() + return@postOnCoreThread + } + + val localAddress = Factory.instance().createAddress(localSipUri) + val remoteAddress = Factory.instance().createAddress(remoteSipUri) + + if (room != null && (!::chatRoom.isInitialized || chatRoom != room)) { + if (localAddress?.weakEqual(room.localAddress) == true && remoteAddress?.weakEqual( + room.peerAddress + ) == true + ) { + Log.i("$TAG Conversation object available in sharedViewModel, using it") + chatRoom = room + loadMediaList() + return@postOnCoreThread + } + } + + if (localAddress != null && remoteAddress != null) { + Log.i("$TAG Searching for conversation in Core using local & peer SIP addresses") + val found = core.searchChatRoom( + null, + localAddress, + remoteAddress, + arrayOfNulls( + 0 + ) + ) + if (found != null) { + chatRoom = found + loadMediaList() + } + } + } + } + + @WorkerThread + private fun loadMediaList() { + val list = arrayListOf() + if (::chatRoom.isInitialized) { + val media = chatRoom.mediaContents + for (mediaContent in media) { + val path = mediaContent.filePath.orEmpty() + val name = mediaContent.name.orEmpty() + val size = mediaContent.size.toLong() + if (path.isNotEmpty() && name.isNotEmpty()) { + val model = FileModel(path, name, size) { + openMediaEvent.postValue(Event(it)) + } + list.add(model) + } + } + } + mediaList.postValue(list) + } +} diff --git a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/MediaViewModel.kt b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/MediaViewModel.kt new file mode 100644 index 000000000..61e9512ee --- /dev/null +++ b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/MediaViewModel.kt @@ -0,0 +1,67 @@ +package org.linphone.ui.main.chat.viewmodel + +import androidx.annotation.UiThread +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import org.linphone.core.tools.Log +import org.linphone.utils.Event +import org.linphone.utils.FileUtils + +class MediaViewModel @UiThread constructor() : ViewModel() { + companion object { + private const val TAG = "[Media ViewModel]" + } + + val path = MutableLiveData() + + val fileName = MutableLiveData() + + val fullScreenMode = MutableLiveData() + + val isImage = MutableLiveData() + + val isVideo = MutableLiveData() + + val isVideoPlaying = MutableLiveData() + + val toggleVideoPlayPauseEvent: MutableLiveData> by lazy { + MutableLiveData>() + } + + private lateinit var filePath: String + + @UiThread + fun loadFile(file: String) { + filePath = file + val name = FileUtils.getNameFromFilePath(file) + fileName.value = name + + val extension = FileUtils.getExtensionFromFileName(name) + val mime = FileUtils.getMimeTypeFromExtension(extension) + when (FileUtils.getMimeType(mime)) { + FileUtils.MimeType.Image -> { + Log.i("$TAG File [$file] seems to be an image") + isImage.value = true + path.value = file + } + FileUtils.MimeType.Video -> { + Log.i("$TAG File [$file] seems to be a video") + isVideo.value = true + isVideoPlaying.value = false + } + else -> { } + } + } + + @UiThread + fun toggleFullScreen() { + fullScreenMode.value = fullScreenMode.value != true + } + + @UiThread + fun playPauseVideo() { + val playVideo = isVideoPlaying.value == false + isVideoPlaying.value = playVideo + toggleVideoPlayPauseEvent.value = Event(playVideo) + } +} diff --git a/app/src/main/java/org/linphone/ui/main/viewer/fragment/FileViewerFragment.kt b/app/src/main/java/org/linphone/ui/main/viewer/fragment/FileViewerFragment.kt index 6f3a6d4d7..ecd80629a 100644 --- a/app/src/main/java/org/linphone/ui/main/viewer/fragment/FileViewerFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/viewer/fragment/FileViewerFragment.kt @@ -20,13 +20,14 @@ import org.linphone.R import org.linphone.core.tools.Log import org.linphone.databinding.FileViewerFragmentBinding import org.linphone.ui.main.MainActivity -import org.linphone.ui.main.fragment.GenericFragment +import org.linphone.ui.main.fragment.SlidingPaneChildFragment import org.linphone.ui.main.viewer.adapter.PdfPagesListAdapter import org.linphone.ui.main.viewer.viewmodel.FileViewModel +import org.linphone.utils.Event import org.linphone.utils.FileUtils @UiThread -class FileViewerFragment : GenericFragment() { +class FileViewerFragment : SlidingPaneChildFragment() { companion object { private const val TAG = "[File Viewer Fragment]" @@ -73,7 +74,11 @@ class FileViewerFragment : GenericFragment() { binding.lifecycleOwner = viewLifecycleOwner binding.viewModel = viewModel - val path = args.path + val path = if (arguments?.containsKey("path") == true) { + requireArguments().getString("path", args.path) + } else { + args.path + } Log.i("$TAG Path argument is [$path]") viewModel.loadFile(path) @@ -103,9 +108,7 @@ class FileViewerFragment : GenericFragment() { if (!copy.isNullOrEmpty()) { sharedViewModel.filesToShareFromIntent.value = arrayListOf(copy) Log.i("$TAG Sharing file [$copy], going back to conversations list") - val action = - FileViewerFragmentDirections.actionFileViewerFragmentToConversationsListFragment() - findNavController().navigate(action) + sharedViewModel.closeSlidingPaneEvent.value = Event(true) } else { Log.e("$TAG Failed to copy file [$filePath] to share!") } diff --git a/app/src/main/java/org/linphone/ui/main/viewmodel/SharedMainViewModel.kt b/app/src/main/java/org/linphone/ui/main/viewmodel/SharedMainViewModel.kt index ac59ede5e..5c4fd7138 100644 --- a/app/src/main/java/org/linphone/ui/main/viewmodel/SharedMainViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/viewmodel/SharedMainViewModel.kt @@ -106,6 +106,8 @@ class SharedMainViewModel @UiThread constructor() : ViewModel() { // When using keyboard to share gif or other, see RichContentReceiver & RichEditText classes val richContentUri = MutableLiveData>() + val mediaViewerFullScreenMode = MutableLiveData() + val forceRefreshConversationInfo: MutableLiveData> by lazy { MutableLiveData>() } @@ -137,8 +139,4 @@ class SharedMainViewModel @UiThread constructor() : ViewModel() { val listOfSelectedSipUrisEvent: MutableLiveData>> by lazy { MutableLiveData>>() } - - val displayFileEvent: MutableLiveData> by lazy { - MutableLiveData>() - } } diff --git a/app/src/main/res/drawable/image.xml b/app/src/main/res/drawable/image.xml new file mode 100644 index 000000000..dca0644d7 --- /dev/null +++ b/app/src/main/res/drawable/image.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/chat_conversation_popup_menu.xml b/app/src/main/res/layout/chat_conversation_popup_menu.xml index c98ed4355..a4b198c1c 100644 --- a/app/src/main/res/layout/chat_conversation_popup_menu.xml +++ b/app/src/main/res/layout/chat_conversation_popup_menu.xml @@ -21,6 +21,9 @@ + @@ -44,7 +47,7 @@ style="@style/default_text_style" android:id="@+id/info" android:onClick="@{goToInfoClickListener}" - android:layout_width="wrap_content" + android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:text="@string/conversation_menu_go_to_info" @@ -56,6 +59,7 @@ android:drawablePadding="5dp" app:drawableTint="?attr/color_main2_700" app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toTopOf="@id/search"/> @@ -63,7 +67,7 @@ style="@style/default_text_style" android:id="@+id/search" android:onClick="@{searchClickListener}" - android:layout_width="wrap_content" + android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginTop="20dp" android:text="@string/conversation_menu_search_in_messages" @@ -75,6 +79,7 @@ android:drawablePadding="5dp" app:drawableTint="?attr/color_main2_700" app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/info" app:layout_constraintBottom_toTopOf="@id/mute"/> @@ -82,7 +87,7 @@ style="@style/default_text_style" android:id="@+id/mute" android:onClick="@{muteClickListener}" - android:layout_width="wrap_content" + android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginTop="20dp" android:text="@string/conversation_action_mute" @@ -95,6 +100,7 @@ android:visibility="@{conversationMuted || readOnlyConversation ? View.GONE : View.VISIBLE}" app:drawableTint="?attr/color_main2_700" app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/search" app:layout_constraintBottom_toTopOf="@id/unmute"/> @@ -122,7 +128,7 @@ style="@style/default_text_style" android:id="@+id/ephemeral" android:onClick="@{configureEphemeralMessagesClickListener}" - android:layout_width="wrap_content" + android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="20dp" android:text="@string/conversation_menu_configure_ephemeral_messages" @@ -135,7 +141,28 @@ android:visibility="@{ephemeralMessagesAvailable && !readOnlyConversation ? View.VISIBLE : View.GONE}" app:drawableTint="?attr/color_main2_700" app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/unmute" + app:layout_constraintBottom_toTopOf="@id/medias"/> + + diff --git a/app/src/main/res/layout/chat_media_content_grid_cell.xml b/app/src/main/res/layout/chat_media_content_grid_cell.xml new file mode 100644 index 000000000..593743674 --- /dev/null +++ b/app/src/main/res/layout/chat_media_content_grid_cell.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/chat_media_fragment.xml b/app/src/main/res/layout/chat_media_fragment.xml new file mode 100644 index 000000000..2503fc5f7 --- /dev/null +++ b/app/src/main/res/layout/chat_media_fragment.xml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/chat_media_viewer_child_fragment.xml b/app/src/main/res/layout/chat_media_viewer_child_fragment.xml new file mode 100644 index 000000000..3289c2358 --- /dev/null +++ b/app/src/main/res/layout/chat_media_viewer_child_fragment.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/chat_media_viewer_fragment.xml b/app/src/main/res/layout/chat_media_viewer_fragment.xml new file mode 100644 index 000000000..c45ce9855 --- /dev/null +++ b/app/src/main/res/layout/chat_media_viewer_fragment.xml @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/chat_nav_graph.xml b/app/src/main/res/navigation/chat_nav_graph.xml index d579686d1..eb1023b30 100644 --- a/app/src/main/res/navigation/chat_nav_graph.xml +++ b/app/src/main/res/navigation/chat_nav_graph.xml @@ -35,6 +35,26 @@ app:destination="@id/emptyFragment" app:popUpTo="@id/conversationFragment" app:popUpToInclusive="true"/> + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/main_nav_graph.xml b/app/src/main/res/navigation/main_nav_graph.xml index 793eb26dc..612f1d015 100644 --- a/app/src/main/res/navigation/main_nav_graph.xml +++ b/app/src/main/res/navigation/main_nav_graph.xml @@ -321,28 +321,4 @@ android:name="conferenceUri" app:argType="string" /> - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 997333093..934b250aa 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -429,6 +429,8 @@ Search Conversation info Ephemeral messages + Medias + No media found No matching result Group members @@ -455,6 +457,8 @@ Ephemeral messages have been disabled Ephemeral lifetime is now %s + Shared media + Read %s Received %s Sent %s @@ -565,7 +569,7 @@ meeting invite: %s voice message - + Contact is trusted Contact is not trusted!