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 da6174a68..4f0bba697 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 @@ -67,11 +67,13 @@ import org.linphone.ui.main.chat.adapter.ConversationEventAdapter import org.linphone.ui.main.chat.model.ChatMessageDeliveryModel import org.linphone.ui.main.chat.model.ChatMessageModel import org.linphone.ui.main.chat.model.ChatMessageReactionsModel +import org.linphone.ui.main.chat.view.RichEditText import org.linphone.ui.main.chat.viewmodel.ConversationViewModel import org.linphone.ui.main.chat.viewmodel.ConversationViewModel.Companion.SCROLLING_POSITION_NOT_SET import org.linphone.ui.main.fragment.GenericFragment import org.linphone.utils.AppUtils import org.linphone.utils.Event +import org.linphone.utils.FileUtils import org.linphone.utils.LinphoneUtils import org.linphone.utils.addCharacterAtPosition import org.linphone.utils.hideKeyboard @@ -98,8 +100,18 @@ class ConversationFragment : GenericFragment() { ActivityResultContracts.PickMultipleVisualMedia() ) { list -> if (!list.isNullOrEmpty()) { - for (file in list) { - Log.i("$TAG Picked file [$file]") + for (uri in list) { + lifecycleScope.launch { + withContext(Dispatchers.IO) { + val path = FileUtils.getFilePath(requireContext(), uri, false) + Log.i("$TAG Picked file [$uri] matching path is [$path]") + if (path != null) { + withContext(Dispatchers.Main) { + viewModel.addAttachment(path) + } + } + } + } } } else { Log.w("$TAG No file picked") @@ -288,6 +300,12 @@ class ConversationFragment : GenericFragment() { } } + viewModel.emojiToAddEvent.observe(viewLifecycleOwner) { + it.consume { emoji -> + binding.sendArea.messageToSend.addCharacterAtPosition(emoji) + } + } + viewModel.participantUsernameToAddEvent.observe(viewLifecycleOwner) { it.consume { username -> Log.i("$TAG Adding username [$username] after '@'") @@ -351,6 +369,33 @@ class ConversationFragment : GenericFragment() { } } + sharedViewModel.richContentUri.observe( + viewLifecycleOwner + ) { + it.consume { uri -> + Log.i("$TAG Found rich content URI: $uri") + lifecycleScope.launch { + withContext(Dispatchers.IO) { + val path = FileUtils.getFilePath(requireContext(), uri, false) + Log.i("$TAG Rich content URI [$uri] matching path is [$path]") + if (path != null) { + withContext(Dispatchers.Main) { + viewModel.addAttachment(path) + } + } + } + } + } + } + + binding.sendArea.messageToSend.setControlEnterListener(object : + RichEditText.RichEditTextSendListener { + override fun onControlEnterPressedAndReleased() { + Log.i("$TAG Detected left control + enter key presses, sending message") + viewModel.sendMessage() + } + }) + binding.root.setKeyboardInsetListener { keyboardVisible -> if (keyboardVisible) { viewModel.isEmojiPickerOpen.value = false diff --git a/app/src/main/java/org/linphone/ui/main/chat/model/FileModel.kt b/app/src/main/java/org/linphone/ui/main/chat/model/FileModel.kt index 275aa470c..b5de388cb 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/model/FileModel.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/model/FileModel.kt @@ -1,19 +1,33 @@ package org.linphone.ui.main.chat.model -import androidx.annotation.WorkerThread +import androidx.annotation.AnyThread +import androidx.annotation.UiThread import androidx.lifecycle.MutableLiveData +import org.linphone.core.tools.Log +import org.linphone.utils.FileUtils -class FileModel @WorkerThread constructor( +class FileModel @AnyThread constructor( val file: String, private val onClicked: ((file: String) -> Unit)? = null ) { + companion object { + private const val TAG = "[File Model]" + } + val path = MutableLiveData() init { path.postValue(file) } + @UiThread fun onClick() { onClicked?.invoke(file) } + + @AnyThread + suspend fun deleteFile() { + Log.i("$TAG Deleting file [$file]") + FileUtils.deleteFile(file) + } } diff --git a/app/src/main/java/org/linphone/ui/main/chat/receiver/RichContentReceiver.kt b/app/src/main/java/org/linphone/ui/main/chat/receiver/RichContentReceiver.kt new file mode 100644 index 000000000..7334f1b58 --- /dev/null +++ b/app/src/main/java/org/linphone/ui/main/chat/receiver/RichContentReceiver.kt @@ -0,0 +1,53 @@ +/* + * 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.receiver + +import android.content.ClipData +import android.net.Uri +import android.view.View +import androidx.core.util.component1 +import androidx.core.util.component2 +import androidx.core.view.ContentInfoCompat +import androidx.core.view.OnReceiveContentListener +import org.linphone.core.tools.Log + +class RichContentReceiver(private val contentReceived: (uri: Uri) -> Unit) : + OnReceiveContentListener { + companion object { + private const val TAG = "[Rich Content Receiver]" + + val MIME_TYPES = arrayOf("image/png", "image/gif", "image/jpeg") + } + + override fun onReceiveContent(view: View, payload: ContentInfoCompat): ContentInfoCompat? { + val (uriContent, remaining) = payload.partition { item -> item.uri != null } + if (uriContent != null) { + val clip: ClipData = uriContent.clip + for (i in 0 until clip.itemCount) { + val uri: Uri = clip.getItemAt(i).uri + Log.i("$TAG Found URI: $uri") + contentReceived(uri) + } + } + // Return anything that your app didn't handle. This preserves the default platform + // behavior for text and anything else that you aren't implementing custom handling for. + return remaining + } +} diff --git a/app/src/main/java/org/linphone/ui/main/chat/view/RichEditText.kt b/app/src/main/java/org/linphone/ui/main/chat/view/RichEditText.kt new file mode 100644 index 000000000..c0b92354e --- /dev/null +++ b/app/src/main/java/org/linphone/ui/main/chat/view/RichEditText.kt @@ -0,0 +1,101 @@ +/* + * 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.view + +import android.app.Activity +import android.content.Context +import android.util.AttributeSet +import android.view.KeyEvent +import androidx.appcompat.widget.AppCompatEditText +import androidx.core.view.ViewCompat +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStoreOwner +import org.linphone.core.tools.Log +import org.linphone.ui.main.chat.receiver.RichContentReceiver +import org.linphone.ui.main.viewmodel.SharedMainViewModel +import org.linphone.utils.Event + +/** + * Allows for image input inside an EditText, usefull for keyboards with gif support for example. + */ +class RichEditText : AppCompatEditText { + companion object { + private const val TAG = "[Rich Edit Text]" + } + + private var controlPressed = false + + private var sendListener: RichEditTextSendListener? = null + + constructor(context: Context) : super(context) { + initReceiveContentListener() + } + + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { + initReceiveContentListener() + } + + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super( + context, + attrs, + defStyleAttr + ) { + initReceiveContentListener() + } + + fun setControlEnterListener(listener: RichEditTextSendListener) { + sendListener = listener + } + + private fun initReceiveContentListener() { + ViewCompat.setOnReceiveContentListener( + this, + RichContentReceiver.MIME_TYPES, + RichContentReceiver { uri -> + Log.i("$TAG Received URI: $uri") + val activity = context as Activity + val sharedViewModel = activity.run { + ViewModelProvider(activity as ViewModelStoreOwner)[SharedMainViewModel::class.java] + } + sharedViewModel.richContentUri.value = Event(uri) + } + ) + + setOnKeyListener { _, keyCode, event -> + if (keyCode == KeyEvent.KEYCODE_CTRL_LEFT) { + if (event.action == KeyEvent.ACTION_DOWN) { + controlPressed = true + } else if (event.action == KeyEvent.ACTION_UP) { + controlPressed = false + } + false + } else if (keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_UP && controlPressed) { + sendListener?.onControlEnterPressedAndReleased() + true + } else { + false + } + } + } + + interface RichEditTextSendListener { + fun onControlEnterPressedAndReleased() + } +} 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 1db564c16..62808ee4e 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 @@ -23,6 +23,8 @@ import androidx.annotation.UiThread import androidx.annotation.WorkerThread import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.R import org.linphone.core.Address @@ -35,6 +37,7 @@ import org.linphone.core.Friend import org.linphone.core.tools.Log import org.linphone.ui.main.chat.model.ChatMessageModel import org.linphone.ui.main.chat.model.EventLogModel +import org.linphone.ui.main.chat.model.FileModel import org.linphone.ui.main.chat.model.ParticipantModel import org.linphone.ui.main.contacts.model.ContactAvatarModel import org.linphone.utils.AppUtils @@ -75,9 +78,9 @@ class ConversationViewModel @UiThread constructor() : ViewModel() { val participants = MutableLiveData>() - val participantUsernameToAddEvent: MutableLiveData> by lazy { - MutableLiveData>() - } + val isFileAttachmentsListOpen = MutableLiveData() + + val attachments = MutableLiveData>() val isReplying = MutableLiveData() @@ -107,6 +110,14 @@ class ConversationViewModel @UiThread constructor() : ViewModel() { MutableLiveData>() } + val emojiToAddEvent: MutableLiveData> by lazy { + MutableLiveData>() + } + + val participantUsernameToAddEvent: MutableLiveData> by lazy { + MutableLiveData>() + } + val chatRoomFoundEvent = MutableLiveData>() lateinit var chatRoom: ChatRoom @@ -210,6 +221,12 @@ class ConversationViewModel @UiThread constructor() : ViewModel() { override fun onCleared() { super.onCleared() + viewModelScope.launch { + for (file in attachments.value.orEmpty()) { + file.deleteFile() + } + } + coreContext.postOnCoreThread { if (::chatRoom.isInitialized) { chatRoom.removeListener(chatRoomListener) @@ -309,7 +326,7 @@ class ConversationViewModel @UiThread constructor() : ViewModel() { @UiThread fun insertEmoji(emoji: String) { - textToSend.value = "${textToSend.value.orEmpty()}$emoji" + emojiToAddEvent.value = Event(emoji) } @UiThread @@ -378,6 +395,61 @@ class ConversationViewModel @UiThread constructor() : ViewModel() { } } + @UiThread + fun closeParticipantsList() { + isParticipantsListOpen.value = false + } + + @UiThread + fun closeFileAttachmentsList() { + viewModelScope.launch { + for (file in attachments.value.orEmpty()) { + file.deleteFile() + } + } + val list = arrayListOf() + attachments.value = list + + isFileAttachmentsListOpen.value = false + } + + @UiThread + fun addAttachment(file: String) { + val list = arrayListOf() + list.addAll(attachments.value.orEmpty()) + val model = FileModel(file) { file -> + removeAttachment(file) + } + list.add(model) + attachments.value = list + + isFileAttachmentsListOpen.value = true + } + + @UiThread + fun removeAttachment(file: String, delete: Boolean = true) { + val list = arrayListOf() + list.addAll(attachments.value.orEmpty()) + val found = list.find { + it.file == file + } + if (found != null) { + if (delete) { + viewModelScope.launch { + found.deleteFile() + } + } + list.remove(found) + } else { + Log.w("$TAG Failed to find file attachment matching [$file]") + } + attachments.value = list + + if (list.isEmpty()) { + isFileAttachmentsListOpen.value = false + } + } + @WorkerThread private fun configureChatRoom() { scrollingPosition = SCROLLING_POSITION_NOT_SET 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 923761faa..9450a6409 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 @@ -19,6 +19,7 @@ */ package org.linphone.ui.main.viewmodel +import android.net.Uri import androidx.annotation.UiThread import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel @@ -106,11 +107,14 @@ class SharedMainViewModel @UiThread constructor() : ViewModel() { MutableLiveData>() } - var displayedChatRoom: ChatRoom? = null + var displayedChatRoom: ChatRoom? = null // Prevents the need to go look for the chat room val showConversationEvent: MutableLiveData>> by lazy { MutableLiveData>>() } + // When using keyboard to share gif or other, see RichContentReceiver & RichEditText classes + val richContentUri = MutableLiveData>() + /* Meetings related */ val showScheduleMeetingEvent: MutableLiveData> by lazy { diff --git a/app/src/main/java/org/linphone/utils/FileUtils.kt b/app/src/main/java/org/linphone/utils/FileUtils.kt index 87546bd31..f21debdc5 100644 --- a/app/src/main/java/org/linphone/utils/FileUtils.kt +++ b/app/src/main/java/org/linphone/utils/FileUtils.kt @@ -156,6 +156,25 @@ class FileUtils { return false } + suspend fun deleteFile(filePath: String) { + withContext(Dispatchers.IO) { + val file = File(filePath) + if (file.exists()) { + try { + if (file.delete()) { + Log.i("$TAG Deleted $filePath") + } else { + Log.e("$TAG Can't delete $filePath") + } + } catch (e: Exception) { + Log.e("$TAG Can't delete $filePath, exception: $e") + } + } else { + Log.e("$TAG File $filePath doesn't exists") + } + } + } + @AnyThread suspend fun dumpStringToFile(data: String, to: File): Boolean { try { diff --git a/app/src/main/res/layout/chat_conversation_attachments_area.xml b/app/src/main/res/layout/chat_conversation_attachments_area.xml new file mode 100644 index 000000000..a7bfce960 --- /dev/null +++ b/app/src/main/res/layout/chat_conversation_attachments_area.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/chat_conversation_fragment.xml b/app/src/main/res/layout/chat_conversation_fragment.xml index b3685314f..d9b7dc2b4 100644 --- a/app/src/main/res/layout/chat_conversation_fragment.xml +++ b/app/src/main/res/layout/chat_conversation_fragment.xml @@ -222,31 +222,10 @@ android:textSize="12sp" android:textColor="@color/gray_main2_400" android:visibility="@{viewModel.composingLabel.length() == 0 ? View.GONE : View.VISIBLE}" - app:layout_constraintBottom_toTopOf="@id/participants" + app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" /> - - - - - - + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/chat_conversation_reply_area.xml b/app/src/main/res/layout/chat_conversation_reply_area.xml index a59598b79..0d00e0674 100644 --- a/app/src/main/res/layout/chat_conversation_reply_area.xml +++ b/app/src/main/res/layout/chat_conversation_reply_area.xml @@ -24,10 +24,9 @@ + app:layout_constraintTop_toTopOf="parent" /> + + + + + app:layout_constraintTop_toBottomOf="@id/attachments" /> + + + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="@id/message_area_background" + app:tint="@color/icon_color_selector" /> + app:layout_constraintTop_toTopOf="@id/message_area_background" + app:tint="@color/icon_color_selector" /> + app:layout_constraintStart_toEndOf="@id/attach_file" + app:layout_constraintTop_toTopOf="@id/message_to_send" /> - + app:layout_constraintStart_toStartOf="@id/message_area_background" + app:layout_constraintTop_toBottomOf="@id/top_barrier" /> + app:layout_constraintEnd_toEndOf="@id/message_area_background" + app:layout_constraintTop_toTopOf="@id/message_area_background" + app:tint="@color/icon_color_selector" /> + app:layout_constraintEnd_toEndOf="@id/message_area_background" + app:layout_constraintTop_toTopOf="@id/message_area_background" + app:tint="@color/icon_primary_color_selector" /> diff --git a/app/src/main/res/layout/chat_info_fragment.xml b/app/src/main/res/layout/chat_info_fragment.xml index 532c1c9e1..642aeed81 100644 --- a/app/src/main/res/layout/chat_info_fragment.xml +++ b/app/src/main/res/layout/chat_info_fragment.xml @@ -232,7 +232,7 @@ android:layout_marginTop="8dp" android:layout_marginBottom="16dp" android:nestedScrollingEnabled="true" - app:layout_constraintHeight_max="300dp" + app:layout_constraintHeight_max="@dimen/chat_room_participants_list_max_height" app:layout_constrainedHeight="true" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" diff --git a/app/src/main/res/values/dimen.xml b/app/src/main/res/values/dimen.xml index f4a23bd8e..0455ad951 100644 --- a/app/src/main/res/values/dimen.xml +++ b/app/src/main/res/values/dimen.xml @@ -74,6 +74,7 @@ 5dp 10dp - 290dp + 300dp + 300dp 425dp \ No newline at end of file