Using new SDK APIs to improve chat message search in conversation

This commit is contained in:
Sylvain Berfini 2024-08-03 10:48:35 +02:00
parent 8a410fc77f
commit 55cd29e710
7 changed files with 243 additions and 72 deletions

View file

@ -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

View file

@ -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,

View file

@ -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<Event<Boolean>>()
}
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)

View file

@ -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<Boolean>()
@ -89,9 +93,13 @@ class ConversationViewModel @UiThread constructor() : AbstractConversationViewMo
val searchFilter = MutableLiveData<String>()
val isUserScrollingUp = MutableLiveData<Boolean>()
val searchInProgress = MutableLiveData<Boolean>()
val noMatchingResultForFilter = MutableLiveData<Boolean>()
val canSearchDown = MutableLiveData<Boolean>()
val itemToScrollTo = MutableLiveData<Int>()
val isUserScrollingUp = MutableLiveData<Boolean>()
val unreadMessagesCount = MutableLiveData<Int>()
@ -127,6 +135,8 @@ class ConversationViewModel @UiThread constructor() : AbstractConversationViewMo
var eventsList = arrayListOf<EventLogModel>()
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<EventLog>) {
Log.i("$TAG Prepending [${eventLogs.size}] events")
// Need to use a new list, otherwise ConversationFragment's dataObserver isn't triggered...
val list = arrayListOf<EventLogModel>()
val firstEvent = eventsList.firstOrNull()
// Prevents message duplicates
val eventsToAdd = arrayListOf<EventLog>()
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<EventLog>
@ -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<EventLog>,
filter: String = ""
history: Array<EventLog>
): ArrayList<EventLogModel> {
val eventsList = arrayListOf<EventLogModel>()
val groupedEventLogs = arrayListOf<EventLog>()
@ -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

View file

@ -63,7 +63,7 @@
<androidx.constraintlayout.widget.Group
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:constraint_referenced_ids="cancel_search, search, clear_field"
app:constraint_referenced_ids="cancel_search, search, search_up, search_down"
android:visibility="@{viewModel.searchBarVisible ? View.VISIBLE : View.GONE, default=gone}" />
<ImageView
@ -198,36 +198,49 @@
app:hintTextColor="?attr/color_main2_400"
app:boxStrokeWidth="0dp"
app:boxStrokeWidthFocused="0dp"
app:layout_constraintEnd_toStartOf="@id/clear_field"
app:layout_constraintEnd_toStartOf="@id/search_up"
app:layout_constraintStart_toEndOf="@id/cancel_search"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/search_field"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:textCursorDrawable="@null"
android:textSize="16sp"
android:inputType="text"
android:paddingVertical="1dp"
android:imeOptions="actionSearch"
android:text="@={viewModel.searchFilter}"
android:background="@android:color/transparent" />
</com.google.android.material.textfield.TextInputLayout>
<ImageView
android:id="@+id/clear_field"
android:onClick="@{() -> viewModel.clearFilter()}"
android:id="@+id/search_up"
android:onClick="@{() -> viewModel.searchUp()}"
android:enabled="@{viewModel.searchFilter.length() > 0}"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="6dp"
android:layout_marginEnd="9dp"
android:src="@drawable/x"
android:contentDescription="@string/content_description_clear_filter"
android:layout_height="0dp"
android:padding="15dp"
android:src="@drawable/caret_up"
app:layout_constraintBottom_toBottomOf="@id/search"
app:layout_constraintEnd_toStartOf="@id/search_down"
app:layout_constraintTop_toTopOf="@id/search"
app:tint="@color/icon_color_selector" />
<ImageView
android:id="@+id/search_down"
android:onClick="@{() -> viewModel.searchDown()}"
android:enabled="@{viewModel.searchFilter.length() > 0 &amp;&amp; viewModel.canSearchDown}"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:padding="15dp"
android:src="@drawable/caret_down"
app:layout_constraintBottom_toBottomOf="@id/search"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/search"
app:tint="?attr/color_main2_500" />
app:tint="@color/icon_color_selector" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/events_list"
@ -249,20 +262,6 @@
app:layout_constraintStart_toStartOf="@id/events_list"
app:layout_constraintEnd_toEndOf="@id/events_list" />
<androidx.appcompat.widget.AppCompatTextView
style="@style/default_text_style_800"
android:id="@+id/no_result"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/list_filter_no_result_found"
android:textColor="?attr/color_main2_600"
android:textSize="16sp"
android:visibility="@{viewModel.noMatchingResultForFilter ? View.VISIBLE : View.GONE}"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/search"
app:layout_constraintBottom_toTopOf="@id/send_area"
app:layout_constraintEnd_toEndOf="parent"/>
<androidx.appcompat.widget.AppCompatTextView
style="@style/default_text_style_300"
android:id="@+id/composing"
@ -340,6 +339,10 @@
</androidx.coordinatorlayout.widget.CoordinatorLayout>
<include
layout="@layout/operation_in_progress"
bind:visibility="@{viewModel.searchInProgress}" />
<include
android:id="@+id/long_press_menu"
android:visibility="@{messageLongPressViewModel.visible ? View.VISIBLE : View.GONE, default=gone}"

View file

@ -462,6 +462,8 @@
<string name="conversation_group_left_toast">Vous avez quitté la conversation</string>
<string name="conversation_no_app_registered_to_handle_content_type_error_toast">Aucune application trouvée pour lire ce fichier</string>
<string name="conversation_to_display_no_found_toast">Conversation non trouvée</string>
<string name="conversation_search_no_match_found">Aucun résultat trouvé</string>
<string name="conversation_search_no_more_match">Dernier résultat atteint</string>
<string name="conversation_info_participants_list_title">Membres du groupe</string>
<string name="conversation_info_add_participants_label">Ajouter des membres</string>

View file

@ -500,6 +500,8 @@
<string name="conversation_group_left_toast">You have left the group</string>
<string name="conversation_no_app_registered_to_handle_content_type_error_toast">No app found to open this kind of file</string>
<string name="conversation_to_display_no_found_toast">Conversation was not found</string>
<string name="conversation_search_no_match_found">No matching result found</string>
<string name="conversation_search_no_more_match">Last matching result reached</string>
<string name="conversation_info_participants_list_title">Group members</string>
<string name="conversation_info_add_participants_label">Add participants</string>