diff --git a/app/src/main/java/org/linphone/ui/main/chat/model/ChatMessageModel.kt b/app/src/main/java/org/linphone/ui/main/chat/model/ChatMessageModel.kt index e86f6f3b0..5a4edb980 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/model/ChatMessageModel.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/model/ChatMessageModel.kt @@ -53,6 +53,7 @@ import org.linphone.ui.main.contacts.model.ContactAvatarModel import org.linphone.utils.AppUtils import org.linphone.utils.AudioRouteUtils import org.linphone.utils.Event +import org.linphone.utils.FileUtils import org.linphone.utils.LinphoneUtils import org.linphone.utils.PatternClickableSpan import org.linphone.utils.SpannableClickedListener @@ -130,6 +131,10 @@ class ChatMessageModel @WorkerThread constructor( val formattedVoiceRecordingDuration = MutableLiveData() + val dismissLongPressMenuEvent: MutableLiveData> by lazy { + MutableLiveData>() + } + private var voiceRecordAudioFocusRequest: AudioFocusRequestCompat? = null private lateinit var voiceRecordPath: String @@ -140,18 +145,29 @@ class ChatMessageModel @WorkerThread constructor( Log.i("$TAG End of file reached") stopVoiceRecordPlayer() } - - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) // End of voice record related fields - val dismissLongPressMenuEvent: MutableLiveData> by lazy { - MutableLiveData>() - } + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private var downloadingFileModel: FileModel? = null private val chatMessageListener = object : ChatMessageListenerStub() { @WorkerThread override fun onMsgStateChanged(message: ChatMessage, messageState: ChatMessage.State?) { - statusIcon.postValue(LinphoneUtils.getChatIconResId(chatMessage.state)) + if ( + messageState == ChatMessage.State.FileTransferInProgress || + messageState == ChatMessage.State.FileTransferDone || + messageState == ChatMessage.State.FileTransferError + ) { + statusIcon.postValue(LinphoneUtils.getChatIconResId(chatMessage.state)) + } + + if (messageState == ChatMessage.State.FileTransferDone) { + Log.i("$TAG File transfer is done") + downloadingFileModel?.downloadProgress?.postValue(-1) + downloadingFileModel = null + computeContentsList() + } } @WorkerThread @@ -167,6 +183,33 @@ class ChatMessageModel @WorkerThread constructor( Log.i("$TAG A reaction was removed for chat message with ID [$id]") updateReactionsList() } + + @WorkerThread + override fun onFileTransferProgressIndication( + message: ChatMessage, + content: Content, + offset: Int, + total: Int + ) { + val model = downloadingFileModel + if (model != null) { + val percent = ((offset * 100.0) / total).toInt() // Conversion from int to double and back to int is required + model.downloadProgress.postValue(percent) + } else { + Log.w("$TAG A file is being downloaded but no downloadingFileModel set!") + val found = filesList.value.orEmpty().find { + it.fileName == content.name + } + if (found != null) { + downloadingFileModel = found + Log.i("$TAG Found matching FileModel in files list using content name") + } else { + Log.w( + "$TAG Failed to find a matching FileModel in files list with content name [${content.name}]" + ) + } + } + } } init { @@ -176,88 +219,7 @@ class ChatMessageModel @WorkerThread constructor( statusIcon.postValue(LinphoneUtils.getChatIconResId(chatMessage.state)) updateReactionsList() - var displayableContentFound = false - var filesContentCount = 0 - val filesPath = arrayListOf() - - val contents = chatMessage.contents - for (content in contents) { - if (content.isIcalendar) { - parseConferenceInvite(content) - displayableContentFound = true - } else if (content.isText) { - computeTextContent(content) - displayableContentFound = true - } else { - filesContentCount += 1 - if (content.isFile) { - val path = content.filePath ?: "" - if (path.isNotEmpty()) { - Log.i( - "$TAG Found file ready to be displayed [$path] with MIME [${content.type}/${content.subtype}] for message [${chatMessage.messageId}]" - ) - when (content.type) { - "image", "video" -> { - val fileModel = FileModel(path, content.fileSize.toLong()) { file -> - onContentClicked?.invoke(file) - } - filesPath.add(fileModel) - - if (filesContentCount == 1) { - firstImage.postValue(fileModel) - } - - displayableContentFound = true - } - "audio" -> { - voiceRecordPath = path - isVoiceRecord.postValue(true) - val duration = content.fileDuration - voiceRecordingDuration.postValue(duration) - val formattedDuration = SimpleDateFormat( - "mm:ss", - Locale.getDefault() - ).format(duration) // duration is in ms - formattedVoiceRecordingDuration.postValue(formattedDuration) - displayableContentFound = true - } - else -> { - val fileModel = FileModel(path, content.fileSize.toLong()) { file -> - onContentClicked?.invoke(file) - } - filesPath.add(fileModel) - - displayableContentFound = true - } - } - } else { - Log.e("$TAG No path found for File Content!") - } - } else if (content.isFileTransfer) { - val name = content.name ?: "" - if (name.isNotEmpty()) { - val fileModel = FileModel(name, content.fileSize.toLong(), true) { file -> - onContentClicked?.invoke(file) - } - filesPath.add(fileModel) - - displayableContentFound = true - } else { - Log.e("$TAG No name found for FileTransfer Content!") - } - } else { - Log.i("$TAG Content is not a File") - } - } - } - - filesList.postValue(filesPath) - - if (!displayableContentFound) { // Temporary workaround to prevent empty bubbles - val describe = LinphoneUtils.getTextDescribingMessage(chatMessage) - val spannable = Spannable.Factory.getInstance().newSpannable(describe) - text.postValue(spannable) - } + computeContentsList() } @WorkerThread @@ -290,8 +252,6 @@ class ChatMessageModel @WorkerThread constructor( coreContext.postOnMainThread { onJoinConferenceClicked?.invoke(uri) } - /*Log.i("$TAG Calling conference URI [${meetingConferenceUri.asStringUriOnly()}]") - coreContext.startCall(meetingConferenceUri)*/ } } } @@ -307,6 +267,111 @@ class ChatMessageModel @WorkerThread constructor( } } + @WorkerThread + private fun computeContentsList() { + Log.d("$TAG Computing chat message contents list") + var displayableContentFound = false + var filesContentCount = 0 + val filesPath = arrayListOf() + + val contents = chatMessage.contents + for (content in contents) { + if (content.isIcalendar) { + parseConferenceInvite(content) + displayableContentFound = true + } else if (content.isText) { + computeTextContent(content) + displayableContentFound = true + } else { + filesContentCount += 1 + if (content.isFile) { + val path = content.filePath ?: "" + if (path.isNotEmpty()) { + Log.d( + "$TAG Found file ready to be displayed [$path] with MIME [${content.type}/${content.subtype}] for message [${chatMessage.messageId}]" + ) + when (content.type) { + "image", "video" -> { + val fileModel = FileModel(path, content.fileSize.toLong()) { model -> + onContentClicked?.invoke(model.file) + } + filesPath.add(fileModel) + + if (filesContentCount == 1) { + firstImage.postValue(fileModel) + } + + displayableContentFound = true + } + "audio" -> { + voiceRecordPath = path + isVoiceRecord.postValue(true) + val duration = content.fileDuration + voiceRecordingDuration.postValue(duration) + val formattedDuration = SimpleDateFormat( + "mm:ss", + Locale.getDefault() + ).format(duration) // duration is in ms + formattedVoiceRecordingDuration.postValue(formattedDuration) + displayableContentFound = true + } + else -> { + val fileModel = FileModel(path, content.fileSize.toLong()) { model -> + onContentClicked?.invoke(model.file) + } + filesPath.add(fileModel) + + displayableContentFound = true + } + } + } else { + Log.e("$TAG No path found for File Content!") + } + } else if (content.isFileTransfer) { + val name = content.name ?: "" + if (name.isNotEmpty()) { + val fileModel = FileModel(name, content.fileSize.toLong(), true) { model -> + Log.i("$TAG Starting downloading content for file [${model.fileName}]") + + if (content.filePath.orEmpty().isEmpty()) { + val contentName = content.name + if (contentName != null) { + val isImage = FileUtils.isExtensionImage(contentName) + val file = FileUtils.getFileStoragePath(contentName, isImage) + content.filePath = file.path + Log.i( + "$TAG File [$contentName] will be downloaded at [${content.filePath}]" + ) + + model.downloadProgress.postValue(0) + downloadingFileModel = model + chatMessage.downloadContent(content) + } else { + Log.e("$TAG Content name is null, can't download it!") + } + } + } + filesPath.add(fileModel) + + displayableContentFound = true + } else { + Log.e("$TAG No name found for FileTransfer Content!") + } + } else { + Log.i("$TAG Content is not a File") + } + } + } + + filesList.postValue(filesPath) + + if (!displayableContentFound) { // Temporary workaround to prevent empty bubbles + val describe = LinphoneUtils.getTextDescribingMessage(chatMessage) + val spannable = Spannable.Factory.getInstance().newSpannable(describe) + text.postValue(spannable) + } + } + @WorkerThread private fun updateReactionsList() { var reactionsList = "" @@ -328,14 +393,13 @@ class ChatMessageModel @WorkerThread constructor( } } - Log.i("$TAG Reactions for message [$id] are [$reactionsList]") + Log.d("$TAG Reactions for message [$id] are [$reactionsList]") reactions.postValue(reactionsList) } @WorkerThread private fun computeTextContent(content: Content) { val textContent = content.utf8Text.orEmpty().trim() - Log.i("$TAG Found text content [$textContent] for message [${chatMessage.messageId}]") val spannableBuilder = SpannableStringBuilder(textContent) // Check for mentions @@ -345,7 +409,7 @@ class ChatMessageModel @WorkerThread constructor( val start = matcher.start() val end = matcher.end() val source = textContent.subSequence(start + 1, end) // +1 to remove @ - Log.i("$TAG Found mention [$source]") + Log.d("$TAG Found mention [$source]") // Find address matching username val address = if (chatRoom.localAddress.username == source) { @@ -362,7 +426,7 @@ class ChatMessageModel @WorkerThread constructor( // Find display name for address if (address != null) { val displayName = coreContext.contactsManager.findDisplayName(address) - Log.i( + Log.d( "$TAG Using display name [$displayName] instead of username [$source]" ) spannableBuilder.replace(start, end, "@$displayName") 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 651a70593..4d45acc7a 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 @@ -11,7 +11,7 @@ class FileModel @AnyThread constructor( val file: String, fileSize: Long, val isWaitingToBeDownloaded: Boolean = false, - private val onClicked: ((file: String) -> Unit)? = null + private val onClicked: ((model: FileModel) -> Unit)? = null ) { companion object { private const val TAG = "[File Model]" @@ -23,6 +23,8 @@ class FileModel @AnyThread constructor( val path = MutableLiveData() + val downloadProgress = MutableLiveData() + val mimeType: FileUtils.MimeType val isImage: Boolean @@ -33,6 +35,7 @@ class FileModel @AnyThread constructor( init { path.postValue(file) + downloadProgress.postValue(-1) formattedFileSize.postValue(FileUtils.bytesToDisplayableSize(fileSize)) if (!isWaitingToBeDownloaded) { @@ -53,7 +56,7 @@ class FileModel @AnyThread constructor( @UiThread fun onClick() { - onClicked?.invoke(file) + onClicked?.invoke(this) } @AnyThread 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 ae54b8a42..6b20bdc90 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 @@ -300,8 +300,8 @@ class SendMessageInConversationViewModel @UiThread constructor() : ViewModel() { fun addAttachment(file: String) { val list = arrayListOf() list.addAll(attachments.value.orEmpty()) - val model = FileModel(file, 0) { file -> - removeAttachment(file) + val model = FileModel(file, 0) { model -> + removeAttachment(model.file) } list.add(model) attachments.value = list diff --git a/app/src/main/java/org/linphone/utils/FileUtils.kt b/app/src/main/java/org/linphone/utils/FileUtils.kt index a0102336c..0cf5e56c8 100644 --- a/app/src/main/java/org/linphone/utils/FileUtils.kt +++ b/app/src/main/java/org/linphone/utils/FileUtils.kt @@ -58,6 +58,13 @@ class FileUtils { return Formatter.formatShortFileSize(coreContext.context, bytes) } + @AnyThread + fun isExtensionImage(path: String): Boolean { + val extension = getExtensionFromFileName(path) + val type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) + return getMimeType(type) == MimeType.Image + } + @AnyThread fun isExtensionVideo(path: String): Boolean { val extension = getExtensionFromFileName(path) diff --git a/app/src/main/java/org/linphone/utils/LinphoneUtils.kt b/app/src/main/java/org/linphone/utils/LinphoneUtils.kt index 38561be26..cf930c823 100644 --- a/app/src/main/java/org/linphone/utils/LinphoneUtils.kt +++ b/app/src/main/java/org/linphone/utils/LinphoneUtils.kt @@ -199,10 +199,10 @@ class LinphoneUtils { ChatMessage.State.Delivered -> { R.drawable.envelope_simple } - ChatMessage.State.InProgress, ChatMessage.State.FileTransferInProgress -> { + ChatMessage.State.InProgress -> { R.drawable.in_progress } - ChatMessage.State.NotDelivered, ChatMessage.State.FileTransferError -> { + ChatMessage.State.NotDelivered -> { R.drawable.warning_circle } else -> { diff --git a/app/src/main/res/layout/chat_bubble_content_grid_cell.xml b/app/src/main/res/layout/chat_bubble_content_grid_cell.xml index dc81052ab..2c58fe3c8 100644 --- a/app/src/main/res/layout/chat_bubble_content_grid_cell.xml +++ b/app/src/main/res/layout/chat_bubble_content_grid_cell.xml @@ -1,6 +1,7 @@ + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools"> @@ -58,6 +59,22 @@ app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent" /> + +