diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchViewModel.kt index b8af6c2f21..e7c5597820 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchViewModel.kt @@ -91,11 +91,15 @@ class ContactSearchViewModel( private val internalFastScrollerEnabled = MutableStateFlow(false) private val internalDisplayingContextMenu = MutableStateFlow(false) private val internalScrollRequests = MutableSharedFlow(extraBufferCapacity = 1) + private val internalSearchInProgress = MutableStateFlow(false) val fastScrollerEnabled: StateFlow = internalFastScrollerEnabled val isDisplayingContextMenu: StateFlow = internalDisplayingContextMenu val scrollRequests: SharedFlow = internalScrollRequests + /** True while a [setConfiguration] call is fetching results off the main thread. Suitable for driving a loading indicator. */ + val searchInProgress: StateFlow = internalSearchInProgress + val query: StateFlow = rawQuery init { @@ -151,17 +155,22 @@ class ContactSearchViewModel( } suspend fun setConfiguration(contactSearchConfiguration: ContactSearchConfiguration) { - val (pagedDataSource, size) = withContext(Dispatchers.IO) { - val source = ContactSearchPagedDataSource( - contactSearchConfiguration, - arbitraryRepository = arbitraryRepository, - searchRepository = searchRepository, - contactSearchPagedDataSourceRepository = contactSearchPagedDataSourceRepository - ) - source to source.size() + internalSearchInProgress.value = true + try { + val (pagedDataSource, size) = withContext(Dispatchers.IO) { + val source = ContactSearchPagedDataSource( + contactSearchConfiguration, + arbitraryRepository = arbitraryRepository, + searchRepository = searchRepository, + contactSearchPagedDataSourceRepository = contactSearchPagedDataSourceRepository + ) + source to source.size() + } + internalTotalCount.value = size + pagedData.value = PagedData.createForStateFlow(pagedDataSource, pagingConfig, data.value) + } finally { + internalSearchInProgress.value = false } - internalTotalCount.value = size - pagedData.value = PagedData.createForStateFlow(pagedDataSource, pagingConfig, data.value) } fun setQuery(query: String?) { @@ -312,3 +321,19 @@ fun ContactSearchViewModel.bindAdapterToLifecycle( } } } + +/** + * Observes [ContactSearchViewModel.searchInProgress] scoped to the given [LifecycleOwner], invoking + * [onSearchInProgressChanged] on the main thread whenever the loading state changes. Designed for Java + * callers that want to drive a loading indicator. + */ +fun ContactSearchViewModel.bindSearchInProgressToLifecycle( + lifecycleOwner: LifecycleOwner, + onSearchInProgressChanged: (Boolean) -> Unit +) { + lifecycleOwner.lifecycleScope.launch { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + searchInProgress.collect { onSearchInProgressChanged(it) } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java index df4c0d1665..5226e40bb4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -208,12 +208,16 @@ public class ConversationListFragment extends MainFragment implements Conversati private static final String TAG = Log.tag(ConversationListFragment.class); + private static final long SEARCH_LOADING_SHOW_DELAY_MS = 150L; + private static final int MAX_CHATS_ABOVE_FOLD = 7; private static final int MAX_CONTACTS_ABOVE_FOLD = 5; private static final int MAX_GROUP_MEMBERSHIPS_ABOVE_FOLD = 5; private View coordinator; private RecyclerView chatFolderList; private RecyclerView list; + private View searchLoading; + private boolean searchInProgress; private Stub bannerView; private ConversationListFilterPullView pullView; private AppBarLayout pullViewAppBarLayout; @@ -221,6 +225,11 @@ public class ConversationListFragment extends MainFragment implements Conversati private RecyclerView.Adapter activeAdapter; private ConversationListAdapter defaultAdapter; private PagingMappingAdapter searchAdapter; + private final Runnable showSearchLoadingRunnable = () -> { + if (searchLoading != null && searchInProgress && activeAdapter == searchAdapter) { + searchLoading.setVisibility(View.VISIBLE); + } + }; private SnapToTopDataObserver snapToTopDataObserver; private Drawable archiveDrawable; private AppForegroundObserver.Listener appForegroundObserver; @@ -317,6 +326,7 @@ public class ConversationListFragment extends MainFragment implements Conversati chatFolderList = view.findViewById(R.id.chat_folder_list); list = view.findViewById(R.id.list); + searchLoading = view.findViewById(R.id.search_loading); bottomActionBar = view.findViewById(R.id.conversation_list_bottom_action_bar); bannerView = new Stub<>(view.findViewById(R.id.banner_compose_view)); voiceNotePlayerViewStub = new Stub<>(view.findViewById(R.id.voice_note_player)); @@ -352,6 +362,11 @@ public class ConversationListFragment extends MainFragment implements Conversati ); ContactSearchViewModelKt.bindAdapterToLifecycle(contactSearchViewModel, getViewLifecycleOwner(), searchAdapter, this::mapSearchStateToConfiguration); + ContactSearchViewModelKt.bindSearchInProgressToLifecycle(contactSearchViewModel, getViewLifecycleOwner(), inProgress -> { + searchInProgress = inProgress; + updateSearchLoadingVisibility(); + return Unit.INSTANCE; + }); initializeSearchFilterListener(); @@ -517,6 +532,11 @@ public class ConversationListFragment extends MainFragment implements Conversati defaultAdapter = null; searchAdapter = null; + if (searchLoading != null) { + searchLoading.removeCallbacks(showSearchLoadingRunnable); + searchLoading = null; + } + dismissProgressDialog(); super.onDestroyView(); @@ -956,6 +976,25 @@ public class ConversationListFragment extends MainFragment implements Conversati } else { defaultAdapter.unregisterAdapterDataObserver(snapToTopDataObserver); } + + updateSearchLoadingVisibility(); + } + + private void updateSearchLoadingVisibility() { + if (searchLoading == null) { + return; + } + + boolean shouldShow = searchInProgress && activeAdapter == searchAdapter; + searchLoading.removeCallbacks(showSearchLoadingRunnable); + + if (shouldShow) { + if (searchLoading.getVisibility() != View.VISIBLE) { + searchLoading.postDelayed(showSearchLoadingRunnable, SEARCH_LOADING_SHOW_DELAY_MS); + } + } else { + searchLoading.setVisibility(View.GONE); + } } private void initializeTypingObserver() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SearchTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/SearchTable.kt index 77857f07ab..b1de7e946f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SearchTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SearchTable.kt @@ -69,55 +69,70 @@ class SearchTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa """ ) + // The FTS MATCH can hit a huge number of rows for broad/short queries. We defer all of the heavy per-row work (column + // materialization and the thread join) until after the LIMIT has cut the result set down to 500 by selecting only the + // matching row ids in the inner query, then joining back for the displayed columns. Note that snippet() is intentionally + // not computed here: it can only be evaluated cheaply inline during the FTS scan (over every match), so it's generated in + // app code from the body of just the returned rows instead. See SearchRepository. @Language("sql") private const val MESSAGES_QUERY = """ - SELECT - ${ThreadTable.TABLE_NAME}.${ThreadTable.RECIPIENT_ID} AS $CONVERSATION_RECIPIENT, - ${MessageTable.TABLE_NAME}.${MessageTable.FROM_RECIPIENT_ID} AS $MESSAGE_RECIPIENT, - snippet($FTS_TABLE_NAME, -1, '', '', '$SNIPPET_WRAP', 7) AS $SNIPPET, - ${MessageTable.TABLE_NAME}.${MessageTable.DATE_RECEIVED}, - $FTS_TABLE_NAME.$THREAD_ID, - $FTS_TABLE_NAME.$BODY, - $FTS_TABLE_NAME.$ID AS $MESSAGE_ID, - 1 AS $IS_MMS - FROM - $FTS_TABLE_NAME - CROSS JOIN ${MessageTable.TABLE_NAME} ON ${MessageTable.TABLE_NAME}.${MessageTable.ID} = $FTS_TABLE_NAME.$ID - INNER JOIN ${ThreadTable.TABLE_NAME} ON $FTS_TABLE_NAME.$THREAD_ID = ${ThreadTable.TABLE_NAME}.${ThreadTable.ID} - WHERE - $FTS_TABLE_NAME MATCH ? AND - ${MessageTable.TABLE_NAME}.${MessageTable.TYPE} & ${MessageTypes.GROUP_V2_BIT} = 0 AND - ${MessageTable.TABLE_NAME}.${MessageTable.TYPE} & ${MessageTypes.SPECIAL_TYPE_PAYMENTS_NOTIFICATION} = 0 AND - ${MessageTable.TABLE_NAME}.${MessageTable.SCHEDULED_DATE} < 0 AND - ${MessageTable.TABLE_NAME}.${MessageTable.LATEST_REVISION_ID} IS NULL - ORDER BY ${MessageTable.DATE_RECEIVED} DESC - LIMIT 500 + SELECT + ${ThreadTable.TABLE_NAME}.${ThreadTable.RECIPIENT_ID} AS $CONVERSATION_RECIPIENT, + ${MessageTable.TABLE_NAME}.${MessageTable.FROM_RECIPIENT_ID} AS $MESSAGE_RECIPIENT, + ${MessageTable.TABLE_NAME}.${MessageTable.BODY}, + ${MessageTable.TABLE_NAME}.${MessageTable.DATE_RECEIVED}, + ${MessageTable.TABLE_NAME}.${MessageTable.THREAD_ID}, + ${MessageTable.TABLE_NAME}.${MessageTable.ID} AS $MESSAGE_ID, + 1 AS $IS_MMS + FROM ( + SELECT $FTS_TABLE_NAME.$ID AS mid + FROM + $FTS_TABLE_NAME + CROSS JOIN ${MessageTable.TABLE_NAME} ON ${MessageTable.TABLE_NAME}.${MessageTable.ID} = $FTS_TABLE_NAME.$ID + INNER JOIN ${ThreadTable.TABLE_NAME} ON $FTS_TABLE_NAME.$THREAD_ID = ${ThreadTable.TABLE_NAME}.${ThreadTable.ID} + WHERE + $FTS_TABLE_NAME MATCH ? AND + ${MessageTable.TABLE_NAME}.${MessageTable.TYPE} & ${MessageTypes.GROUP_V2_BIT} = 0 AND + ${MessageTable.TABLE_NAME}.${MessageTable.TYPE} & ${MessageTypes.SPECIAL_TYPE_PAYMENTS_NOTIFICATION} = 0 AND + ${MessageTable.TABLE_NAME}.${MessageTable.SCHEDULED_DATE} < 0 AND + ${MessageTable.TABLE_NAME}.${MessageTable.LATEST_REVISION_ID} IS NULL + ORDER BY ${MessageTable.TABLE_NAME}.${MessageTable.DATE_RECEIVED} DESC + LIMIT 500 + ) AS limited + JOIN ${MessageTable.TABLE_NAME} ON ${MessageTable.TABLE_NAME}.${MessageTable.ID} = limited.mid + JOIN ${ThreadTable.TABLE_NAME} ON ${MessageTable.TABLE_NAME}.${MessageTable.THREAD_ID} = ${ThreadTable.TABLE_NAME}.${ThreadTable.ID} + ORDER BY ${MessageTable.TABLE_NAME}.${MessageTable.DATE_RECEIVED} DESC """ @Language("sql") private const val MESSAGES_FOR_THREAD_QUERY = """ - SELECT - ${ThreadTable.TABLE_NAME}.${ThreadTable.RECIPIENT_ID} AS $CONVERSATION_RECIPIENT, + SELECT + ${ThreadTable.TABLE_NAME}.${ThreadTable.RECIPIENT_ID} AS $CONVERSATION_RECIPIENT, ${MessageTable.TABLE_NAME}.${MessageTable.FROM_RECIPIENT_ID} AS $MESSAGE_RECIPIENT, - snippet($FTS_TABLE_NAME, -1, '', '', '$SNIPPET_WRAP', 7) AS $SNIPPET, - ${MessageTable.TABLE_NAME}.${MessageTable.DATE_RECEIVED}, - $FTS_TABLE_NAME.$THREAD_ID, - $FTS_TABLE_NAME.$BODY, - $FTS_TABLE_NAME.$ID AS $MESSAGE_ID, - 1 AS $IS_MMS - FROM - $FTS_TABLE_NAME - CROSS JOIN ${MessageTable.TABLE_NAME} ON ${MessageTable.TABLE_NAME}.${MessageTable.ID} = $FTS_TABLE_NAME.$ID - INNER JOIN ${ThreadTable.TABLE_NAME} ON $FTS_TABLE_NAME.$THREAD_ID = ${ThreadTable.TABLE_NAME}.${ThreadTable.ID} - WHERE - $FTS_TABLE_NAME MATCH ? AND - ${MessageTable.TABLE_NAME}.${MessageTable.THREAD_ID} = ? AND - ${MessageTable.TABLE_NAME}.${MessageTable.TYPE} & ${MessageTypes.GROUP_V2_BIT} = 0 AND - ${MessageTable.TABLE_NAME}.${MessageTable.TYPE} & ${MessageTypes.SPECIAL_TYPE_PAYMENTS_NOTIFICATION} = 0 AND - ${MessageTable.TABLE_NAME}.${MessageTable.SCHEDULED_DATE} < 0 AND - ${MessageTable.TABLE_NAME}.${MessageTable.LATEST_REVISION_ID} IS NULL - ORDER BY ${MessageTable.DATE_RECEIVED} DESC - LIMIT 500 + ${MessageTable.TABLE_NAME}.${MessageTable.BODY}, + ${MessageTable.TABLE_NAME}.${MessageTable.DATE_RECEIVED}, + ${MessageTable.TABLE_NAME}.${MessageTable.THREAD_ID}, + ${MessageTable.TABLE_NAME}.${MessageTable.ID} AS $MESSAGE_ID, + 1 AS $IS_MMS + FROM ( + SELECT $FTS_TABLE_NAME.$ID AS mid + FROM + $FTS_TABLE_NAME + CROSS JOIN ${MessageTable.TABLE_NAME} ON ${MessageTable.TABLE_NAME}.${MessageTable.ID} = $FTS_TABLE_NAME.$ID + INNER JOIN ${ThreadTable.TABLE_NAME} ON $FTS_TABLE_NAME.$THREAD_ID = ${ThreadTable.TABLE_NAME}.${ThreadTable.ID} + WHERE + $FTS_TABLE_NAME MATCH ? AND + ${MessageTable.TABLE_NAME}.${MessageTable.THREAD_ID} = ? AND + ${MessageTable.TABLE_NAME}.${MessageTable.TYPE} & ${MessageTypes.GROUP_V2_BIT} = 0 AND + ${MessageTable.TABLE_NAME}.${MessageTable.TYPE} & ${MessageTypes.SPECIAL_TYPE_PAYMENTS_NOTIFICATION} = 0 AND + ${MessageTable.TABLE_NAME}.${MessageTable.SCHEDULED_DATE} < 0 AND + ${MessageTable.TABLE_NAME}.${MessageTable.LATEST_REVISION_ID} IS NULL + ORDER BY ${MessageTable.TABLE_NAME}.${MessageTable.DATE_RECEIVED} DESC + LIMIT 500 + ) AS limited + JOIN ${MessageTable.TABLE_NAME} ON ${MessageTable.TABLE_NAME}.${MessageTable.ID} = limited.mid + JOIN ${ThreadTable.TABLE_NAME} ON ${MessageTable.TABLE_NAME}.${MessageTable.THREAD_ID} = ${ThreadTable.TABLE_NAME}.${ThreadTable.ID} + ORDER BY ${MessageTable.TABLE_NAME}.${MessageTable.DATE_RECEIVED} DESC """ } @@ -160,8 +175,8 @@ class SearchTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa @Language("sql") val filteredQuery = MESSAGES_QUERY.replace( - "ORDER BY ${MessageTable.DATE_RECEIVED} DESC", - "$extraConditions ORDER BY ${MessageTable.DATE_RECEIVED} DESC" + "${MessageTable.TABLE_NAME}.${MessageTable.LATEST_REVISION_ID} IS NULL", + "${MessageTable.TABLE_NAME}.${MessageTable.LATEST_REVISION_ID} IS NULL$extraConditions" ) return readableDatabase.rawQuery(filteredQuery, args.toTypedArray()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java b/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java index ab1906a138..1ecee6aaae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java @@ -9,10 +9,10 @@ import android.text.TextUtils; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import androidx.annotation.WorkerThread; import org.signal.core.util.CursorUtil; -import org.signal.core.util.StringUtil; import org.signal.core.util.concurrent.SignalExecutors; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.conversation.MessageStyler; @@ -46,6 +46,7 @@ import java.util.HashSet; import java.util.LinkedHashSet; import java.util.LinkedList; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Set; @@ -60,6 +61,8 @@ public class SearchRepository { private static final String TAG = Log.tag(SearchRepository.class); + private static final int MAX_SNIPPET_SIZE = 100; + private final Context context; private final String noteToSelfTitle; private final SearchTable searchDatabase; @@ -183,6 +186,12 @@ public class SearchRepository { results = readToList(cursor, new MessageModelBuilder()); } + if (results.isEmpty()) { + return results; + } + + List snippetQueries = tokenizeQuery(query); + List messageIds = new LinkedList<>(); for (MessageResult result : results) { if (result.isMms()) { @@ -190,121 +199,65 @@ public class SearchRepository { } } - if (messageIds.isEmpty()) { - return results; - } - - Map bodyRanges = SignalDatabase.messages().getBodyRangesForMessages(messageIds); - Map> mentions = SignalDatabase.mentions().getMentionsForMessages(messageIds); - - if (bodyRanges.isEmpty() && mentions.isEmpty()) { - return results; - } + Map bodyRanges = messageIds.isEmpty() ? Collections.emptyMap() : SignalDatabase.messages().getBodyRangesForMessages(messageIds); + Map> mentions = messageIds.isEmpty() ? Collections.emptyMap() : SignalDatabase.mentions().getMentionsForMessages(messageIds); List updatedResults = new ArrayList<>(results.size()); for (MessageResult result : results) { - if (bodyRanges.containsKey(result.getMessageId()) || mentions.containsKey(result.getMessageId())) { - CharSequence body = result.getBody(); - CharSequence bodySnippet = result.getBodySnippet(); - CharSequence updatedBody = body; - List bodyAdjustments = Collections.emptyList(); - CharSequence updatedSnippet = bodySnippet; - List snippetAdjustments = Collections.emptyList(); - List messageMentions = mentions.get(result.getMessageId()); - BodyRangeList ranges = bodyRanges.get(result.getMessageId()); + CharSequence updatedBody = result.getBody(); + List bodyAdjustments = Collections.emptyList(); + List messageMentions = mentions.get(result.getMessageId()); + BodyRangeList ranges = bodyRanges.get(result.getMessageId()); - if (messageMentions != null) { - MentionUtil.UpdatedBodyAndMentions bodyMentionUpdate = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, body, messageMentions); - updatedBody = Objects.requireNonNull(bodyMentionUpdate.getBody()); - bodyAdjustments = bodyMentionUpdate.getBodyAdjustments(); - - MentionUtil.UpdatedBodyAndMentions snippetMentionUpdate = updateSnippetWithDisplayNames(body, bodySnippet, messageMentions); - updatedSnippet = Objects.requireNonNull(snippetMentionUpdate.getBody()); - snippetAdjustments = snippetMentionUpdate.getBodyAdjustments(); - } - - if (ranges != null) { - updatedBody = SpannableString.valueOf(updatedBody); - MessageStyler.style(result.getReceivedTimestampMs(), BodyRangeUtil.adjustBodyRanges(ranges, bodyAdjustments), (Spannable) updatedBody); - - updatedSnippet = SpannableString.valueOf(updatedSnippet); - updateSnippetWithStyles(result.getReceivedTimestampMs(), updatedBody, (SpannableString) updatedSnippet, BodyRangeUtil.adjustBodyRanges(ranges, snippetAdjustments)); - } - - updatedResults.add(new MessageResult(result.getConversationRecipient(), result.getMessageRecipient(), updatedBody, updatedSnippet, result.getThreadId(), result.getMessageId(), result.getReceivedTimestampMs(), result.isMms())); - } else { - updatedResults.add(result); + if (messageMentions != null) { + MentionUtil.UpdatedBodyAndMentions bodyMentionUpdate = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, updatedBody, messageMentions); + updatedBody = Objects.requireNonNull(bodyMentionUpdate.getBody()); + bodyAdjustments = bodyMentionUpdate.getBodyAdjustments(); } + + if (ranges != null) { + updatedBody = SpannableString.valueOf(updatedBody); + MessageStyler.style(result.getReceivedTimestampMs(), BodyRangeUtil.adjustBodyRanges(ranges, bodyAdjustments), (Spannable) updatedBody); + } + + CharSequence updatedSnippet = makeSnippet(snippetQueries, updatedBody); + + updatedResults.add(new MessageResult(result.getConversationRecipient(), result.getMessageRecipient(), updatedBody, updatedSnippet, result.getThreadId(), result.getMessageId(), result.getReceivedTimestampMs(), result.isMms())); } return updatedResults; } - private @NonNull MentionUtil.UpdatedBodyAndMentions updateSnippetWithDisplayNames(@NonNull CharSequence body, @NonNull CharSequence bodySnippet, @NonNull List mentions) { - CharSequence cleanSnippet = bodySnippet; - int startOffset = 0; - - if (StringUtil.startsWith(cleanSnippet, SNIPPET_WRAP)) { - cleanSnippet = cleanSnippet.subSequence(SNIPPET_WRAP.length(), cleanSnippet.length()); - startOffset = SNIPPET_WRAP.length(); - } - - if (StringUtil.endsWith(cleanSnippet, SNIPPET_WRAP)) { - cleanSnippet = cleanSnippet.subSequence(0, cleanSnippet.length() - SNIPPET_WRAP.length()); - } - - int startIndex = TextUtils.indexOf(body, cleanSnippet); - - if (startIndex != -1) { - List adjustMentions = new ArrayList<>(mentions.size()); - for (Mention mention : mentions) { - int adjustedStart = mention.getStart() - startIndex + startOffset; - if (adjustedStart >= 0 && adjustedStart + mention.getLength() <= cleanSnippet.length()) { - adjustMentions.add(new Mention(mention.getRecipientId(), adjustedStart, mention.getLength())); - } - } - - return MentionUtil.updateBodyAndMentionsWithDisplayNames(context, bodySnippet, adjustMentions); - } else { - return MentionUtil.updateBodyAndMentionsWithDisplayNames(context, bodySnippet, Collections.emptyList()); - } - } - - private void updateSnippetWithStyles(long id, @NonNull CharSequence body, @NonNull SpannableString bodySnippet, @NonNull BodyRangeList bodyRanges) { - CharSequence cleanSnippet = bodySnippet; - int startOffset = 0; - - if (StringUtil.startsWith(cleanSnippet, SNIPPET_WRAP)) { - cleanSnippet = cleanSnippet.subSequence(SNIPPET_WRAP.length(), cleanSnippet.length()); - startOffset = SNIPPET_WRAP.length(); - } - - if (StringUtil.endsWith(cleanSnippet, SNIPPET_WRAP)) { - cleanSnippet = cleanSnippet.subSequence(0, cleanSnippet.length() - SNIPPET_WRAP.length()); - } - - int startIndex = TextUtils.indexOf(body, cleanSnippet); - - if (startIndex != -1) { - List newRanges = new ArrayList<>(bodyRanges.ranges.size()); - for (BodyRangeList.BodyRange range : bodyRanges.ranges) { - int adjustedStart = range.start - startIndex + startOffset; - if (adjustedStart >= 0 && adjustedStart + range.length <= bodySnippet.length()) { - newRanges.add(range.newBuilder().start(adjustedStart).build()); - } - } - - BodyRangeList.Builder builder = new BodyRangeList.Builder(); - builder.ranges(newRanges); - - MessageStyler.style(id, builder.build(), bodySnippet); - } - } - private @NonNull List queryMessages(@NonNull String query, long threadId) { + List results; try (Cursor cursor = searchDatabase.queryMessages(query, threadId)) { - return readToList(cursor, new MessageModelBuilder()); + results = readToList(cursor, new MessageModelBuilder()); } + + if (results.isEmpty()) { + return results; + } + + List snippetQueries = tokenizeQuery(query); + List updatedResults = new ArrayList<>(results.size()); + for (MessageResult result : results) { + CharSequence snippet = makeSnippet(snippetQueries, result.getBody()); + updatedResults.add(new MessageResult(result.getConversationRecipient(), result.getMessageRecipient(), result.getBody(), snippet, result.getThreadId(), result.getMessageId(), result.getReceivedTimestampMs(), result.isMms())); + } + + return updatedResults; + } + + @VisibleForTesting + static @NonNull List tokenizeQuery(@NonNull String query) { + List tokens = new ArrayList<>(); + for (String part : query.split("\\s+")) { + String trimmed = part.trim(); + if (!trimmed.isEmpty()) { + tokens.add(trimmed); + } + } + return tokens; } private @NonNull List queryMentions(@NonNull List cleanQueries) { @@ -371,14 +324,15 @@ public class SearchRepository { return results; } - private @NonNull CharSequence makeSnippet(@NonNull List queries, @NonNull CharSequence styledBody) { + @VisibleForTesting + static @NonNull CharSequence makeSnippet(@NonNull List queries, @NonNull CharSequence styledBody) { if (styledBody.length() < 50) { return styledBody; } - String lowerBody = styledBody.toString().toLowerCase(); + String lowerBody = styledBody.toString().toLowerCase(Locale.ROOT); for (String query : queries) { - int foundIndex = lowerBody.indexOf(query.toLowerCase()); + int foundIndex = lowerBody.indexOf(query.toLowerCase(Locale.ROOT)); if (foundIndex != -1) { int snippetStart = Math.max(0, Math.max(TextUtils.lastIndexOf(styledBody,' ', foundIndex - 5) + 1, foundIndex - 15)); int lastSpace = TextUtils.indexOf(styledBody, ' ', foundIndex + 30); @@ -390,7 +344,15 @@ public class SearchRepository { } } - return styledBody; + if (styledBody.length() <= MAX_SNIPPET_SIZE) { + return styledBody; + } + + int lastSpace = TextUtils.lastIndexOf(styledBody, ' ', MAX_SNIPPET_SIZE); + int snippetEnd = lastSpace > 0 ? lastSpace : MAX_SNIPPET_SIZE; + + return new SpannableStringBuilder().append(styledBody.subSequence(0, snippetEnd)) + .append(SNIPPET_WRAP); } private @NonNull List readToList(@Nullable Cursor cursor, @NonNull ModelBuilder builder) { @@ -489,7 +451,6 @@ public class SearchRepository { Recipient conversationRecipient = Recipient.resolved(conversationRecipientId); Recipient messageRecipient = Recipient.resolved(messageRecipientId); String body = CursorUtil.requireString(cursor, SearchTable.BODY); - String bodySnippet = CursorUtil.requireString(cursor, SearchTable.SNIPPET); long receivedMs = CursorUtil.requireLong(cursor, MessageTable.DATE_RECEIVED); long threadId = CursorUtil.requireLong(cursor, MessageTable.THREAD_ID); int messageId = CursorUtil.requireInt(cursor, SearchTable.MESSAGE_ID); @@ -499,11 +460,7 @@ public class SearchRepository { body = ""; } - if (bodySnippet == null) { - bodySnippet = ""; - } - - return new MessageResult(conversationRecipient, messageRecipient, body, bodySnippet, threadId, messageId, receivedMs, isMms); + return new MessageResult(conversationRecipient, messageRecipient, body, body, threadId, messageId, receivedMs, isMms); } } diff --git a/app/src/main/res/layout/conversation_list_fragment.xml b/app/src/main/res/layout/conversation_list_fragment.xml index e364cc0a18..d28f4830c8 100644 --- a/app/src/main/res/layout/conversation_list_fragment.xml +++ b/app/src/main/res/layout/conversation_list_fragment.xml @@ -88,6 +88,20 @@ tools:visibility="visible" /> + +