From 55cd29e710c3f66dd6864beec1ad712b4496b025 Mon Sep 17 00:00:00 2001 From: Sylvain Berfini Date: Sat, 3 Aug 2024 10:48:35 +0200 Subject: [PATCH] Using new SDK APIs to improve chat message search in conversation --- .../chat/fragment/ConversationFragment.kt | 35 +++- .../ui/main/chat/model/EventLogModel.kt | 2 + .../ui/main/chat/model/MessageModel.kt | 38 +++- .../chat/viewmodel/ConversationViewModel.kt | 185 ++++++++++++++---- .../res/layout/chat_conversation_fragment.xml | 51 ++--- app/src/main/res/values-fr/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + 7 files changed, 243 insertions(+), 72 deletions(-) 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 e701d3613..b1efafd75 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 @@ -34,6 +34,7 @@ import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.view.ViewTreeObserver +import android.view.inputmethod.EditorInfo import android.widget.PopupWindow import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts @@ -108,7 +109,7 @@ open class ConversationFragment : SlidingPaneChildFragment() { protected lateinit var sendMessageViewModel: SendMessageInConversationViewModel - protected lateinit var messageLongPressViewModel: ChatMessageLongPressViewModel + private lateinit var messageLongPressViewModel: ChatMessageLongPressViewModel private lateinit var adapter: ConversationEventAdapter @@ -211,6 +212,12 @@ open class ConversationFragment : SlidingPaneChildFragment() { override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { if (positionStart > 0) { adapter.notifyItemChanged(positionStart - 1) // For grouping purposes + } else if (adapter.itemCount != itemCount) { + if (viewModel.searchInProgress.value == true) { + val recyclerView = binding.eventsList + recyclerView.scrollToPosition(viewModel.itemToScrollTo.value ?: 0) + viewModel.searchInProgress.postValue(false) + } } if (viewModel.isUserScrollingUp.value == true) { @@ -567,6 +574,15 @@ open class ConversationFragment : SlidingPaneChildFragment() { showUnsafeConversationDetailsBottomSheet() } + binding.searchField.setOnEditorActionListener { view, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_SEARCH) { + view.hideKeyboard() + viewModel.searchUp() + return@setOnEditorActionListener true + } + false + } + sendMessageViewModel.emojiToAddEvent.observe(viewLifecycleOwner) { it.consume { emoji -> binding.sendArea.messageToSend.addCharacterAtPosition(emoji) @@ -594,10 +610,6 @@ open class ConversationFragment : SlidingPaneChildFragment() { } } - viewModel.searchFilter.observe(viewLifecycleOwner) { filter -> - viewModel.applyFilter(filter.trim()) - } - viewModel.focusSearchBarEvent.observe(viewLifecycleOwner) { it.consume { show -> if (show) { @@ -665,6 +677,13 @@ open class ConversationFragment : SlidingPaneChildFragment() { } } + viewModel.itemToScrollTo.observe(viewLifecycleOwner) { position -> + if (position >= 0) { + val recyclerView = binding.eventsList + recyclerView.scrollToPosition(position) + } + } + messageLongPressViewModel.replyToMessageEvent.observe(viewLifecycleOwner) { it.consume { val model = messageLongPressViewModel.messageModel.value @@ -757,7 +776,7 @@ open class ConversationFragment : SlidingPaneChildFragment() { sharedViewModel.forceRefreshConversationEvents.observe(viewLifecycleOwner) { it.consume { Log.i("$TAG Force refreshing messages list") - viewModel.applyFilter("") + viewModel.applyFilter() } } @@ -788,7 +807,9 @@ open class ConversationFragment : SlidingPaneChildFragment() { scrollListener = object : ConversationScrollListener(layoutManager) { @UiThread override fun onLoadMore(totalItemsCount: Int) { - viewModel.loadMoreData(totalItemsCount) + if (viewModel.searchInProgress.value == false) { + viewModel.loadMoreData(totalItemsCount) + } } @UiThread diff --git a/app/src/main/java/org/linphone/ui/main/chat/model/EventLogModel.kt b/app/src/main/java/org/linphone/ui/main/chat/model/EventLogModel.kt index 52cd68e3f..debac6393 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/model/EventLogModel.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/model/EventLogModel.kt @@ -32,6 +32,7 @@ class EventLogModel @WorkerThread constructor( isFromGroup: Boolean = false, isGroupedWithPreviousOne: Boolean = false, isGroupedWithNextOne: Boolean = false, + currentFilter: String = "", onContentClicked: ((fileModel: FileModel) -> Unit)? = null, onJoinConferenceClicked: ((uri: String) -> Unit)? = null, onWebUrlClicked: ((url: String) -> Unit)? = null, @@ -82,6 +83,7 @@ class EventLogModel @WorkerThread constructor( chatMessage.isForward, isGroupedWithPreviousOne, isGroupedWithNextOne, + currentFilter, onContentClicked, onJoinConferenceClicked, onWebUrlClicked, diff --git a/app/src/main/java/org/linphone/ui/main/chat/model/MessageModel.kt b/app/src/main/java/org/linphone/ui/main/chat/model/MessageModel.kt index 5bbdaf37e..dac228528 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/model/MessageModel.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/model/MessageModel.kt @@ -19,10 +19,12 @@ */ package org.linphone.ui.main.chat.model +import android.graphics.Typeface import android.os.CountDownTimer import android.text.Spannable import android.text.SpannableStringBuilder import android.text.Spanned +import android.text.style.StyleSpan import androidx.annotation.UiThread import androidx.annotation.WorkerThread import androidx.lifecycle.MediatorLiveData @@ -74,6 +76,7 @@ class MessageModel @WorkerThread constructor( val isForward: Boolean, isGroupedWithPreviousOne: Boolean, isGroupedWithNextOne: Boolean, + private val currentFilter: String = "", private val onContentClicked: ((fileModel: FileModel) -> Unit)? = null, private val onJoinConferenceClicked: ((uri: String) -> Unit)? = null, private val onWebUrlClicked: ((url: String) -> Unit)? = null, @@ -164,6 +167,8 @@ class MessageModel @WorkerThread constructor( MutableLiveData>() } + var isTextHighlighted = false + private var voiceRecordAudioFocusRequest: AudioFocusRequestCompat? = null private lateinit var voiceRecordPath: String @@ -354,7 +359,7 @@ class MessageModel @WorkerThread constructor( displayableContentFound = true } else if (content.isText && !content.isFile) { Log.d("$TAG Found plain text content") - computeTextContent(content) + computeTextContent(content, currentFilter) displayableContentFound = true } else if (content.isVoiceRecording) { @@ -554,10 +559,39 @@ class MessageModel @WorkerThread constructor( } @WorkerThread - private fun computeTextContent(content: Content) { + fun highlightText(highlight: String) { + if (isTextHighlighted && highlight.isEmpty()) { + isTextHighlighted = false + } + + val textContent = chatMessage.contents.find { + it.isText + } + if (textContent != null) { + computeTextContent(textContent, highlight) + } + } + + @WorkerThread + private fun computeTextContent(content: Content, highlight: String) { val textContent = content.utf8Text.orEmpty().trim() val spannableBuilder = SpannableStringBuilder(textContent) + // Check for search + if (highlight.isNotEmpty()) { + val indexStart = textContent.indexOf(highlight, 0, ignoreCase = true) + if (indexStart >= 0) { + isTextHighlighted = true + val indexEnd = indexStart + highlight.length + spannableBuilder.setSpan( + StyleSpan(Typeface.BOLD), + indexStart, + indexEnd, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + } + // Check for mentions val chatRoom = chatMessage.chatRoom val matcher = Pattern.compile(MENTION_REGEXP).matcher(textContent) 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 26b28a6d7..3fc8423c7 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 @@ -33,6 +33,7 @@ import org.linphone.core.Address import org.linphone.core.ChatMessage 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 @@ -41,6 +42,7 @@ 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 import org.linphone.ui.main.chat.model.FileModel @@ -59,6 +61,8 @@ class ConversationViewModel @UiThread constructor() : AbstractConversationViewMo const val MAX_TIME_TO_GROUP_MESSAGES = 60 // 1 minute const val SCROLLING_POSITION_NOT_SET = -1 + + const val ITEMS_TO_LOAD_BEFORE_SEARCH_RESULT = 6 } val showBackButton = MutableLiveData() @@ -89,9 +93,13 @@ class ConversationViewModel @UiThread constructor() : AbstractConversationViewMo val searchFilter = MutableLiveData() - val isUserScrollingUp = MutableLiveData() + val searchInProgress = MutableLiveData() - val noMatchingResultForFilter = MutableLiveData() + val canSearchDown = MutableLiveData() + + val itemToScrollTo = MutableLiveData() + + val isUserScrollingUp = MutableLiveData() val unreadMessagesCount = MutableLiveData() @@ -127,6 +135,8 @@ class ConversationViewModel @UiThread constructor() : AbstractConversationViewMo var eventsList = arrayListOf() + private var latestMatch: EventLog? = null + private val chatRoomListener = object : ChatRoomListenerStub() { @WorkerThread override fun onConferenceJoined(chatRoom: ChatRoom, eventLog: EventLog) { @@ -308,6 +318,9 @@ class ConversationViewModel @UiThread constructor() : AbstractConversationViewMo searchBarVisible.value = false isUserScrollingUp.value = false isDisabledBecauseNotSecured.value = false + searchInProgress.value = false + canSearchDown.value = false + itemToScrollTo.value = -1 } override fun onCleared() { @@ -341,14 +354,33 @@ class ConversationViewModel @UiThread constructor() : AbstractConversationViewMo @UiThread fun closeSearchBar() { - clearFilter() + searchFilter.value = "" searchBarVisible.value = false focusSearchBarEvent.value = Event(false) + latestMatch = null + canSearchDown.value = false + + coreContext.postOnCoreThread { + for (eventLog in eventsList) { + if ((eventLog.model as? MessageModel)?.isTextHighlighted == true) { + eventLog.model.highlightText("") + } + } + } } @UiThread - fun clearFilter() { - searchFilter.value = "" + fun searchUp() { + coreContext.postOnCoreThread { + searchChatMessage(SearchDirection.Up) + } + } + + @UiThread + fun searchDown() { + coreContext.postOnCoreThread { + searchChatMessage(SearchDirection.Down) + } } @UiThread @@ -360,9 +392,9 @@ class ConversationViewModel @UiThread constructor() : AbstractConversationViewMo } @UiThread - fun applyFilter(filter: String) { + fun applyFilter() { coreContext.postOnCoreThread { - computeEvents(filter) + computeEvents() } } @@ -471,7 +503,7 @@ class ConversationViewModel @UiThread constructor() : AbstractConversationViewMo } val history = chatRoom.getHistoryRangeEvents(totalItemsCount, upperBound) - val list = getEventsListFromHistory(history, searchFilter.value.orEmpty()) + val list = getEventsListFromHistory(history) val lastEvent = list.lastOrNull() val newEvent = eventsList.firstOrNull() @@ -574,21 +606,15 @@ class ConversationViewModel @UiThread constructor() : AbstractConversationViewMo } @WorkerThread - private fun computeEvents(filter: String = "") { + private fun computeEvents() { eventsList.forEach(EventLogModel::destroy) val history = chatRoom.getHistoryEvents(MESSAGES_PER_PAGE) - val list = getEventsListFromHistory(history, filter) + val list = getEventsListFromHistory(history) Log.i("$TAG Extracted [${list.size}] events from conversation history in database") eventsList = list updateEvents.postValue(Event(true)) isEmpty.postValue(eventsList.isEmpty()) - - if (filter.isNotEmpty() && eventsList.isEmpty()) { - noMatchingResultForFilter.postValue(true) - } else { - noMatchingResultForFilter.postValue(false) - } } @WorkerThread @@ -619,8 +645,7 @@ class ConversationViewModel @UiThread constructor() : AbstractConversationViewMo } val newList = getEventsListFromHistory( - eventsToAdd.toTypedArray(), - searchFilter.value.orEmpty().trim() + eventsToAdd.toTypedArray() ) val newEvent = newList.firstOrNull() @@ -639,6 +664,38 @@ class ConversationViewModel @UiThread constructor() : AbstractConversationViewMo isEmpty.postValue(eventsList.isEmpty()) } + @WorkerThread + private fun prependEvents(eventLogs: Array) { + Log.i("$TAG Prepending [${eventLogs.size}] events") + // Need to use a new list, otherwise ConversationFragment's dataObserver isn't triggered... + val list = arrayListOf() + val firstEvent = eventsList.firstOrNull() + + // Prevents message duplicates + val eventsToAdd = arrayListOf() + eventsToAdd.addAll(eventLogs) + + val newList = getEventsListFromHistory( + eventsToAdd.toTypedArray() + ) + val lastEvent = newList.lastOrNull() + + if (lastEvent != null && lastEvent.model is MessageModel && firstEvent != null && firstEvent.model is MessageModel && shouldWeGroupTwoEvents( + firstEvent.eventLog, + lastEvent.eventLog + ) + ) { + lastEvent.model.groupedWithNextMessage.postValue(true) + firstEvent.model.groupedWithPreviousMessage.postValue(true) + } + + list.addAll(newList) + list.addAll(eventsList) + eventsList = list + updateEvents.postValue(Event(true)) + isEmpty.postValue(eventsList.isEmpty()) + } + @WorkerThread private fun processGroupedEvents( groupedEventLogs: ArrayList @@ -657,6 +714,7 @@ class ConversationViewModel @UiThread constructor() : AbstractConversationViewMo groupChatRoom, index > 0, index != groupedEventLogs.size - 1, + searchFilter.value.orEmpty(), { fileModel -> fileToDisplayEvent.postValue(Event(fileModel)) }, @@ -683,8 +741,7 @@ class ConversationViewModel @UiThread constructor() : AbstractConversationViewMo @WorkerThread private fun getEventsListFromHistory( - history: Array, - filter: String = "" + history: Array ): ArrayList { val eventsList = arrayListOf() val groupedEventLogs = arrayListOf() @@ -695,25 +752,6 @@ class ConversationViewModel @UiThread constructor() : AbstractConversationViewMo eventsList.addAll(processGroupedEvents(arrayListOf(event))) } else { for (event in history) { - if (filter.isNotEmpty()) { - if (event.type == EventLog.Type.ConferenceChatMessage) { - val message = event.chatMessage ?: continue - val fromAddress = message.fromAddress - val model = coreContext.contactsManager.getContactAvatarModelForAddress( - fromAddress - ) - if ( - !model.name.value.orEmpty().contains(filter, ignoreCase = true) && - !fromAddress.asStringUriOnly().contains(filter, ignoreCase = true) && - !message.utf8Text.orEmpty().contains(filter, ignoreCase = true) - ) { - continue - } - } else { - continue - } - } - if (groupedEventLogs.isEmpty()) { groupedEventLogs.add(event) continue @@ -814,6 +852,75 @@ class ConversationViewModel @UiThread constructor() : AbstractConversationViewMo } } + @WorkerThread + private fun searchChatMessage(direction: SearchDirection) { + searchInProgress.postValue(true) + + val textToSearch = searchFilter.value.orEmpty().trim() + val match = chatRoom.searchChatMessageByText(textToSearch, latestMatch, direction) + if (match == null) { + Log.i( + "$TAG No match found while looking up for message with text [$textToSearch] in direction [$direction] starting from message [${latestMatch?.chatMessage?.messageId}]" + ) + searchInProgress.postValue(false) + val message = if (latestMatch == null) { + R.string.conversation_search_no_match_found + } else { + R.string.conversation_search_no_more_match + } + showRedToastEvent.postValue( + Event( + Pair(message, R.drawable.magnifying_glass) + ) + ) + } else { + Log.i( + "$TAG Found result [${match.chatMessage?.messageId}] while looking up for message with text [$textToSearch] in direction [$direction] starting from message [${latestMatch?.chatMessage?.messageId}]" + ) + latestMatch = match + + val found = eventsList.find { + it.eventLog == match + } + if (found == null) { + Log.i("$TAG Found result isn't in currently loaded history, loading missing events") + val historyToAdd = chatRoom.getHistoryRangeBetween( + latestMatch, + eventsList[0].eventLog, + HistoryFilter.None.toInt() + ) + Log.i("$TAG Loaded [${historyToAdd.size}] items from history") + + Log.i( + "$TAG Also loading [$ITEMS_TO_LOAD_BEFORE_SEARCH_RESULT] items before the match" + ) + val previousMessages = chatRoom.getHistoryRangeNear( + ITEMS_TO_LOAD_BEFORE_SEARCH_RESULT, + 0, + match, + HistoryFilter.None.toInt() + ) + + itemToScrollTo.postValue(previousMessages.size - 1) + val toAdd = previousMessages.plus(historyToAdd) + prependEvents(toAdd) + } else { + Log.i("$TAG Found result is already in history, no need to load more history") + (found.model as? MessageModel)?.highlightText(textToSearch) + val index = eventsList.indexOf(found) + if (direction == SearchDirection.Down && index < eventsList.size - 1) { + // Go to next message to prevent the message we are looking for to be behind the scroll to bottom button + itemToScrollTo.postValue(index + 1) + } else { + itemToScrollTo.postValue(index) + } + searchInProgress.postValue(false) + } + + canSearchDown.postValue(true) + } + } + @WorkerThread private fun createGroupCall() { val core = coreContext.core diff --git a/app/src/main/res/layout/chat_conversation_fragment.xml b/app/src/main/res/layout/chat_conversation_fragment.xml index 70c047db6..a7bb2b8af 100644 --- a/app/src/main/res/layout/chat_conversation_fragment.xml +++ b/app/src/main/res/layout/chat_conversation_fragment.xml @@ -63,7 +63,7 @@ + + + app:tint="@color/icon_color_selector" /> - - + + Vous avez quitté la conversation Aucune application trouvée pour lire ce fichier Conversation non trouvée + Aucun résultat trouvé + Dernier résultat atteint Membres du groupe Ajouter des membres diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5e60d1454..5fdd2a698 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -500,6 +500,8 @@ You have left the group No app found to open this kind of file Conversation was not found + No matching result found + Last matching result reached Group members Add participants