From 63cb7d663052e158eb78437a65a1d7a36cca8ef2 Mon Sep 17 00:00:00 2001 From: Sylvain Berfini Date: Mon, 23 Sep 2024 14:43:34 +0200 Subject: [PATCH] Added confirmation dialog before starting a group call from a group conversation --- .../chat/fragment/ConversationFragment.kt | 29 +++++ .../chat/fragment/ConversationInfoFragment.kt | 29 +++++ .../AbstractConversationViewModel.kt | 106 ++++++++++++++++++ .../viewmodel/ConversationInfoViewModel.kt | 98 ---------------- .../chat/viewmodel/ConversationViewModel.kt | 98 ---------------- .../java/org/linphone/utils/DialogUtils.kt | 17 +++ .../main/res/layout/chat_info_fragment.xml | 4 +- .../dialog_set_or_edit_group_subject.xml | 2 +- ...log_start_group_call_from_conversation.xml | 105 +++++++++++++++++ .../layout/dialog_update_account_password.xml | 2 +- ...ccount_password_after_register_failure.xml | 2 +- app/src/main/res/values-fr/strings.xml | 5 +- app/src/main/res/values/strings.xml | 5 +- 13 files changed, 299 insertions(+), 203 deletions(-) create mode 100644 app/src/main/res/layout/dialog_start_group_call_from_conversation.xml 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 e6dfca835..915bc0c6e 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 @@ -512,6 +512,12 @@ open class ConversationFragment : SlidingPaneChildFragment() { } } + viewModel.confirmGroupCallEvent.observe(viewLifecycleOwner) { + it.consume { + showConfirmGroupCallPopup() + } + } + viewModel.isEndToEndEncrypted.observe(viewLifecycleOwner) { encrypted -> if (encrypted) { binding.eventsList.addItemDecoration(headerItemDecoration) @@ -1369,6 +1375,29 @@ open class ConversationFragment : SlidingPaneChildFragment() { dialog.show() } + private fun showConfirmGroupCallPopup() { + val model = ConfirmationDialogModel() + val dialog = DialogUtils.getConfirmGroupCallDialog( + requireActivity(), + model + ) + + model.dismissEvent.observe(viewLifecycleOwner) { + it.consume { + dialog.dismiss() + } + } + + model.confirmEvent.observe(viewLifecycleOwner) { + it.consume { + viewModel.startGroupCall() + dialog.dismiss() + } + } + + dialog.show() + } + private fun openFileInAnotherApp(path: String, mime: String) { val intent = Intent(Intent.ACTION_VIEW) val contentUri: Uri = diff --git a/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationInfoFragment.kt b/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationInfoFragment.kt index 41dc57346..f25a4acf1 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationInfoFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationInfoFragment.kt @@ -184,6 +184,12 @@ class ConversationInfoFragment : SlidingPaneChildFragment() { } } + viewModel.confirmGroupCallEvent.observe(viewLifecycleOwner) { + it.consume { + showConfirmGroupCallPopup() + } + } + sharedViewModel.listOfSelectedSipUrisEvent.observe(viewLifecycleOwner) { it.consume { list -> Log.i("$TAG Found [${list.size}] new participants to add to the group, let's do it") @@ -428,6 +434,29 @@ class ConversationInfoFragment : SlidingPaneChildFragment() { dialog.show() } + private fun showConfirmGroupCallPopup() { + val model = ConfirmationDialogModel() + val dialog = DialogUtils.getConfirmGroupCallDialog( + requireActivity(), + model + ) + + model.dismissEvent.observe(viewLifecycleOwner) { + it.consume { + dialog.dismiss() + } + } + + model.confirmEvent.observe(viewLifecycleOwner) { + it.consume { + viewModel.startGroupCall() + dialog.dismiss() + } + } + + dialog.show() + } + private fun copyAddressToClipboard(value: String) { val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager clipboard.setPrimaryClip(ClipData.newPlainText("SIP address", value)) diff --git a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/AbstractConversationViewModel.kt b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/AbstractConversationViewModel.kt index 9958ca1ef..c411b67be 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/AbstractConversationViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/AbstractConversationViewModel.kt @@ -23,11 +23,17 @@ import androidx.annotation.UiThread import androidx.annotation.WorkerThread import androidx.lifecycle.MutableLiveData import org.linphone.LinphoneApplication.Companion.coreContext +import org.linphone.R import org.linphone.core.ChatRoom +import org.linphone.core.ConferenceScheduler +import org.linphone.core.ConferenceSchedulerListenerStub import org.linphone.core.Factory +import org.linphone.core.Participant +import org.linphone.core.ParticipantInfo import org.linphone.core.tools.Log import org.linphone.ui.GenericViewModel import org.linphone.utils.Event +import org.linphone.utils.LinphoneUtils abstract class AbstractConversationViewModel : GenericViewModel() { companion object { @@ -38,6 +44,10 @@ abstract class AbstractConversationViewModel : GenericViewModel() { MutableLiveData>() } + val confirmGroupCallEvent: MutableLiveData> by lazy { + MutableLiveData>() + } + lateinit var chatRoom: ChatRoom lateinit var localSipUri: String @@ -48,6 +58,47 @@ abstract class AbstractConversationViewModel : GenericViewModel() { return ::chatRoom.isInitialized } + private val conferenceSchedulerListener = object : ConferenceSchedulerListenerStub() { + override fun onStateChanged( + conferenceScheduler: ConferenceScheduler, + state: ConferenceScheduler.State + ) { + Log.i("$TAG Conference scheduler state is $state") + if (state == ConferenceScheduler.State.Ready) { + conferenceScheduler.removeListener(this) + + val conferenceAddress = conferenceScheduler.info?.uri + if (conferenceAddress != null) { + Log.i( + "$TAG Conference info created, address is ${conferenceAddress.asStringUriOnly()}" + ) + coreContext.startVideoCall(conferenceAddress) + } else { + Log.e("$TAG Conference info URI is null!") + showRedToastEvent.postValue( + Event( + Pair( + R.string.conference_failed_to_create_group_call_toast, + R.drawable.warning_circle + ) + ) + ) + } + } else if (state == ConferenceScheduler.State.Error) { + conferenceScheduler.removeListener(this) + Log.e("$TAG Failed to create group call!") + showRedToastEvent.postValue( + Event( + Pair( + R.string.conference_failed_to_create_group_call_toast, + R.drawable.warning_circle + ) + ) + ) + } + } + } + @WorkerThread open fun beforeNotifyingChatRoomFound(sameOne: Boolean) { } @@ -128,4 +179,59 @@ abstract class AbstractConversationViewModel : GenericViewModel() { } } } + + @UiThread + fun startCall() { + coreContext.postOnCoreThread { + if (LinphoneUtils.isChatRoomAGroup(chatRoom) && chatRoom.participants.size >= 2) { + confirmGroupCallEvent.postValue(Event(true)) + } else { + val firstParticipant = chatRoom.participants.firstOrNull() + val address = firstParticipant?.address + if (address != null) { + Log.i("$TAG Audio calling SIP address [${address.asStringUriOnly()}]") + coreContext.startAudioCall(address) + } else { + Log.e("$TAG Failed to find participant to call!") + } + } + } + } + + @UiThread + fun startGroupCall() { + coreContext.postOnCoreThread { core -> + val account = core.defaultAccount + if (account == null) { + Log.e( + "$TAG No default account found, can't create group call!" + ) + return@postOnCoreThread + } + + val conferenceInfo = Factory.instance().createConferenceInfo() + conferenceInfo.organizer = account.params.identityAddress + conferenceInfo.subject = chatRoom.subject + + val participants = arrayOfNulls(chatRoom.participants.size) + var index = 0 + for (participant in chatRoom.participants) { + val info = Factory.instance().createParticipantInfo(participant.address) + // For meetings, all participants must have Speaker role + info?.role = Participant.Role.Speaker + participants[index] = info + index += 1 + } + conferenceInfo.setParticipantInfos(participants) + + Log.i( + "$TAG Creating group call with subject ${conferenceInfo.subject} and ${participants.size} participant(s)" + ) + val conferenceScheduler = core.createConferenceScheduler() + conferenceScheduler.addListener(conferenceSchedulerListener) + conferenceScheduler.account = account + // Will trigger the conference creation/update automatically + conferenceScheduler.info = conferenceInfo + } + } } diff --git a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationInfoViewModel.kt b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationInfoViewModel.kt index c2bfe0ba9..6d59e7310 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationInfoViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationInfoViewModel.kt @@ -30,13 +30,10 @@ import org.linphone.contacts.ContactsManager import org.linphone.core.Address import org.linphone.core.ChatRoom import org.linphone.core.ChatRoomListenerStub -import org.linphone.core.ConferenceScheduler -import org.linphone.core.ConferenceSchedulerListenerStub import org.linphone.core.EventLog import org.linphone.core.Factory import org.linphone.core.Friend import org.linphone.core.Participant -import org.linphone.core.ParticipantInfo import org.linphone.core.tools.Log import org.linphone.ui.main.chat.model.ParticipantModel import org.linphone.ui.main.contacts.model.ContactAvatarModel @@ -196,47 +193,6 @@ class ConversationInfoViewModel @UiThread constructor() : AbstractConversationVi } } - private val conferenceSchedulerListener = object : ConferenceSchedulerListenerStub() { - override fun onStateChanged( - conferenceScheduler: ConferenceScheduler, - state: ConferenceScheduler.State - ) { - Log.i("$TAG Conference scheduler state is $state") - if (state == ConferenceScheduler.State.Ready) { - conferenceScheduler.removeListener(this) - - val conferenceAddress = conferenceScheduler.info?.uri - if (conferenceAddress != null) { - Log.i( - "$TAG Conference info created, address is ${conferenceAddress.asStringUriOnly()}" - ) - coreContext.startVideoCall(conferenceAddress) - } else { - Log.e("$TAG Conference info URI is null!") - showRedToastEvent.postValue( - Event( - Pair( - R.string.conference_failed_to_create_group_call_toast, - R.drawable.warning_circle - ) - ) - ) - } - } else if (state == ConferenceScheduler.State.Error) { - conferenceScheduler.removeListener(this) - Log.e("$TAG Failed to create group call!") - showRedToastEvent.postValue( - Event( - Pair( - R.string.conference_failed_to_create_group_call_toast, - R.drawable.warning_circle - ) - ) - ) - } - } - } - private val contactsListener = object : ContactsManager.ContactsListener { @WorkerThread override fun onContactsLoaded() { @@ -304,24 +260,6 @@ class ConversationInfoViewModel @UiThread constructor() : AbstractConversationVi } } - @UiThread - fun call() { - coreContext.postOnCoreThread { core -> - if (LinphoneUtils.isChatRoomAGroup(chatRoom) && chatRoom.participants.size >= 2) { - createGroupCall() - } else { - val firstParticipant = chatRoom.participants.firstOrNull() - val address = firstParticipant?.address - if (address != null) { - Log.i("$TAG Audio calling SIP address [${address.asStringUriOnly()}]") - coreContext.startAudioCall(address) - } else { - Log.e("$TAG Failed to find participant to call!") - } - } - } - } - @UiThread fun scheduleMeeting() { coreContext.postOnCoreThread { @@ -637,42 +575,6 @@ class ConversationInfoViewModel @UiThread constructor() : AbstractConversationVi participants.postValue(participantsList) } - @WorkerThread - private fun createGroupCall() { - val core = coreContext.core - val account = core.defaultAccount - if (account == null) { - Log.e( - "$TAG No default account found, can't create group call!" - ) - return - } - - val conferenceInfo = Factory.instance().createConferenceInfo() - conferenceInfo.organizer = account.params.identityAddress - conferenceInfo.subject = subject.value - - val participants = arrayOfNulls(chatRoom.participants.size) - var index = 0 - for (participant in chatRoom.participants) { - val info = Factory.instance().createParticipantInfo(participant.address) - // For meetings, all participants must have Speaker role - info?.role = Participant.Role.Speaker - participants[index] = info - index += 1 - } - conferenceInfo.setParticipantInfos(participants) - - Log.i( - "$TAG Creating group call with subject ${subject.value} and ${participants.size} participant(s)" - ) - val conferenceScheduler = core.createConferenceScheduler() - conferenceScheduler.addListener(conferenceSchedulerListener) - conferenceScheduler.account = account - // Will trigger the conference creation/update automatically - conferenceScheduler.info = conferenceInfo - } - @WorkerThread private fun getParticipant(eventLog: EventLog): String { val participantAddress = eventLog.participantAddress 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 09714b0e8..69251abdf 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 @@ -36,13 +36,8 @@ import org.linphone.core.ChatMessageReaction import org.linphone.core.ChatRoom import org.linphone.core.ChatRoom.HistoryFilter import org.linphone.core.ChatRoomListenerStub -import org.linphone.core.ConferenceScheduler -import org.linphone.core.ConferenceSchedulerListenerStub import org.linphone.core.EventLog -import org.linphone.core.Factory import org.linphone.core.Friend -import org.linphone.core.Participant -import org.linphone.core.ParticipantInfo import org.linphone.core.SearchDirection import org.linphone.core.tools.Log import org.linphone.ui.main.chat.model.EventLogModel @@ -281,47 +276,6 @@ class ConversationViewModel @UiThread constructor() : AbstractConversationViewMo } } - private val conferenceSchedulerListener = object : ConferenceSchedulerListenerStub() { - override fun onStateChanged( - conferenceScheduler: ConferenceScheduler, - state: ConferenceScheduler.State - ) { - Log.i("$TAG Conference scheduler state is $state") - if (state == ConferenceScheduler.State.Ready) { - conferenceScheduler.removeListener(this) - - val conferenceAddress = conferenceScheduler.info?.uri - if (conferenceAddress != null) { - Log.i( - "$TAG Conference info created, address is ${conferenceAddress.asStringUriOnly()}" - ) - coreContext.startVideoCall(conferenceAddress) - } else { - Log.e("$TAG Conference info URI is null!") - showRedToastEvent.postValue( - Event( - Pair( - R.string.conference_failed_to_create_group_call_toast, - R.drawable.warning_circle - ) - ) - ) - } - } else if (state == ConferenceScheduler.State.Error) { - conferenceScheduler.removeListener(this) - Log.e("$TAG Failed to create group call!") - showRedToastEvent.postValue( - Event( - Pair( - R.string.conference_failed_to_create_group_call_toast, - R.drawable.warning_circle - ) - ) - ) - } - } - } - private val contactsListener = object : ContactsManager.ContactsListener { @WorkerThread override fun onContactsLoaded() { @@ -463,22 +417,6 @@ class ConversationViewModel @UiThread constructor() : AbstractConversationViewMo } } - @UiThread - fun startCall() { - coreContext.postOnCoreThread { core -> - if (LinphoneUtils.isChatRoomAGroup(chatRoom) && chatRoom.participants.size >= 2) { - createGroupCall() - } else { - val firstParticipant = chatRoom.participants.firstOrNull() - val address = firstParticipant?.address - if (address != null) { - Log.i("$TAG Audio calling SIP address [${address.asStringUriOnly()}]") - coreContext.startAudioCall(address) - } - } - } - } - @UiThread fun markAsRead() { coreContext.postOnCoreThread { @@ -987,42 +925,6 @@ class ConversationViewModel @UiThread constructor() : AbstractConversationViewMo } } - @WorkerThread - private fun createGroupCall() { - val core = coreContext.core - val account = core.defaultAccount - if (account == null) { - Log.e( - "$TAG No default account found, can't create group call!" - ) - return - } - - val conferenceInfo = Factory.instance().createConferenceInfo() - conferenceInfo.organizer = account.params.identityAddress - conferenceInfo.subject = subject.value - - val participants = arrayOfNulls(chatRoom.participants.size) - var index = 0 - for (participant in chatRoom.participants) { - val info = Factory.instance().createParticipantInfo(participant.address) - // For meetings, all participants must have Speaker role - info?.role = Participant.Role.Speaker - participants[index] = info - index += 1 - } - conferenceInfo.setParticipantInfos(participants) - - Log.i( - "$TAG Creating group call with subject ${subject.value} and ${participants.size} participant(s)" - ) - val conferenceScheduler = core.createConferenceScheduler() - conferenceScheduler.addListener(conferenceSchedulerListener) - conferenceScheduler.account = account - // Will trigger the conference creation/update automatically - conferenceScheduler.info = conferenceInfo - } - @UiThread fun copyFileToUri(filePath: String, dest: Uri) { val source = Uri.parse(FileUtils.getProperFilePath(filePath)) diff --git a/app/src/main/java/org/linphone/utils/DialogUtils.kt b/app/src/main/java/org/linphone/utils/DialogUtils.kt index 473284c42..c49f0349e 100644 --- a/app/src/main/java/org/linphone/utils/DialogUtils.kt +++ b/app/src/main/java/org/linphone/utils/DialogUtils.kt @@ -51,6 +51,7 @@ import org.linphone.databinding.DialogRemoveAllCallLogsBinding import org.linphone.databinding.DialogRemoveCallLogsBinding import org.linphone.databinding.DialogRemoveConversationHistoryBinding import org.linphone.databinding.DialogSetOrEditGroupSubjectBindingImpl +import org.linphone.databinding.DialogStartGroupCallFromConversationBinding import org.linphone.databinding.DialogUpdateAccountPasswordAfterRegisterFailureBinding import org.linphone.databinding.DialogUpdateAccountPasswordBinding import org.linphone.databinding.DialogUpdateAvailableBinding @@ -281,6 +282,22 @@ class DialogUtils { return getDialog(context, binding) } + @UiThread + fun getConfirmGroupCallDialog( + context: Context, + viewModel: ConfirmationDialogModel + ): Dialog { + val binding: DialogStartGroupCallFromConversationBinding = DataBindingUtil.inflate( + LayoutInflater.from(context), + R.layout.dialog_start_group_call_from_conversation, + null, + false + ) + binding.viewModel = viewModel + + return getDialog(context, binding) + } + @UiThread fun getDeleteConversationHistoryConfirmationDialog( context: Context, diff --git a/app/src/main/res/layout/chat_info_fragment.xml b/app/src/main/res/layout/chat_info_fragment.xml index 3277e9f9c..56e3f5de7 100644 --- a/app/src/main/res/layout/chat_info_fragment.xml +++ b/app/src/main/res/layout/chat_info_fragment.xml @@ -224,7 +224,7 @@ android:layout_height="56dp" android:layout_marginTop="40dp" android:background="@drawable/circle_light_blue_button_background" - android:onClick="@{() -> viewModel.call()}" + android:onClick="@{() -> viewModel.startCall()}" android:padding="16dp" android:src="@drawable/phone" android:contentDescription="@string/content_description_call_start" @@ -240,7 +240,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="8dp" - android:onClick="@{() -> viewModel.call()}" + android:onClick="@{() -> viewModel.startCall()}" android:text="@string/conversation_action_call" android:textSize="14sp" android:visibility="@{viewModel.isReadOnly ? View.GONE : View.VISIBLE}" diff --git a/app/src/main/res/layout/dialog_set_or_edit_group_subject.xml b/app/src/main/res/layout/dialog_set_or_edit_group_subject.xml index 01083d6e8..41c3d7c68 100644 --- a/app/src/main/res/layout/dialog_set_or_edit_group_subject.xml +++ b/app/src/main/res/layout/dialog_set_or_edit_group_subject.xml @@ -113,7 +113,7 @@ android:layout_marginStart="15dp" android:layout_marginEnd="15dp" android:enabled="@{!viewModel.emptySubject}" - android:text="@string/conversation_dialog_edit_subject_confirm_button" + android:text="@string/dialog_confirm" app:layout_constraintStart_toStartOf="@id/dialog_background" app:layout_constraintEnd_toEndOf="@id/dialog_background" app:layout_constraintTop_toBottomOf="@id/cancel" diff --git a/app/src/main/res/layout/dialog_start_group_call_from_conversation.xml b/app/src/main/res/layout/dialog_start_group_call_from_conversation.xml new file mode 100644 index 000000000..ba50679e3 --- /dev/null +++ b/app/src/main/res/layout/dialog_start_group_call_from_conversation.xml @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_update_account_password.xml b/app/src/main/res/layout/dialog_update_account_password.xml index a125e1e65..66e48ed88 100644 --- a/app/src/main/res/layout/dialog_update_account_password.xml +++ b/app/src/main/res/layout/dialog_update_account_password.xml @@ -107,7 +107,7 @@ android:layout_marginTop="16dp" android:layout_marginStart="15dp" android:layout_marginEnd="15dp" - android:text="@string/conversation_dialog_edit_subject_confirm_button" + android:text="@string/dialog_confirm" app:layout_constraintStart_toStartOf="@id/dialog_background" app:layout_constraintEnd_toEndOf="@id/dialog_background" app:layout_constraintTop_toBottomOf="@id/cancel" diff --git a/app/src/main/res/layout/dialog_update_account_password_after_register_failure.xml b/app/src/main/res/layout/dialog_update_account_password_after_register_failure.xml index 5e015eb22..8b8468a4a 100644 --- a/app/src/main/res/layout/dialog_update_account_password_after_register_failure.xml +++ b/app/src/main/res/layout/dialog_update_account_password_after_register_failure.xml @@ -123,7 +123,7 @@ android:layout_marginTop="16dp" android:layout_marginStart="15dp" android:layout_marginEnd="15dp" - android:text="@string/conversation_dialog_edit_subject_confirm_button" + android:text="@string/dialog_confirm" app:layout_constraintStart_toStartOf="@id/dialog_background" app:layout_constraintEnd_toEndOf="@id/dialog_background" app:layout_constraintTop_toBottomOf="@id/cancel" diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 7d59bc9a6..37344911e 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -43,6 +43,7 @@ Non Oui Retirer + Confirmer &appName; notifications d\'appels en cours @@ -444,7 +445,6 @@ Renommer la conversation Un nom est obligatoire Nom de la conversation - Confirmer Ouvrir ou sauvegarder le fichier ? &appName; ne peut ouvrir ce fichier.\n\nVoulez-vous l\'ouvrir dans une autre app (si possible), ou le sauvegarder sur votre appareil ? Ouvrir le fichier @@ -485,6 +485,9 @@ %s is no longer admin Contact non trouvé Aucune adresse à ajouter au contact + Démarrer un appel de groupe ? + Tous les participants de la conversation recevront un appel. + Démarrer l\'appel de groupe Vous avez rejoint le groupe Vous avez quitté le groupe diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e584f1816..5d339e213 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -79,6 +79,7 @@ No Yes Remove + Confirm &appName; active calls notifications @@ -482,7 +483,6 @@ Edit conversation subject Subject is mandatory Conversation subject - Confirm Open or export file? &appName; can\'t open this file.\n\nDo you want to open it in another app (if possible), or export it on your device? Open file @@ -523,6 +523,9 @@ %s is no longer admin Contact was not found No address to add to contact + Start a group call? + All participants will receive a call. + Start a group call You have joined the group You have left the group