Improve search performance.

This commit is contained in:
Greyson Parrelli 2021-06-10 15:47:12 -04:00 committed by GitHub
parent 53ffca964d
commit c274ed6a96
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 413 additions and 219 deletions

View file

@ -42,6 +42,7 @@ import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter;
import org.thoughtcrime.securesms.profiles.ProfileName;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.CursorUtil;
import org.thoughtcrime.securesms.util.SqlUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
@ -179,41 +180,6 @@ public class ContactAccessor {
return contactData;
}
public List<String> getNumbersForThreadSearchFilter(Context context, String constraint) {
LinkedList<String> numberList = new LinkedList<>();
try (Cursor cursor = DatabaseFactory.getRecipientDatabase(context).queryAllContacts(constraint)) {
while (cursor != null && cursor.moveToNext()) {
String phone = cursor.getString(cursor.getColumnIndexOrThrow(RecipientDatabase.PHONE));
String email = cursor.getString(cursor.getColumnIndexOrThrow(RecipientDatabase.EMAIL));
numberList.add(Util.getFirstNonEmpty(phone, email));
}
}
GroupDatabase.Reader reader = null;
GroupRecord record;
try {
reader = DatabaseFactory.getGroupDatabase(context).getGroupsFilteredByTitle(constraint, true, false);
while ((record = reader.getNext()) != null) {
numberList.add(record.getId().toString());
}
} finally {
if (reader != null)
reader.close();
}
if (context.getString(R.string.note_to_self).toLowerCase().contains(constraint.toLowerCase()) &&
!numberList.contains(TextSecurePreferences.getLocalNumber(context)))
{
numberList.add(TextSecurePreferences.getLocalNumber(context));
}
return numberList;
}
public CharSequence phoneTypeToString(Context mContext, int type, CharSequence label) {
return Phone.getTypeLabel(mContext.getResources(), type, label);
}

View file

@ -141,7 +141,7 @@ import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationM
import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog;
import org.thoughtcrime.securesms.conversation.ui.groupcall.GroupCallViewModel;
import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerViewModel;
import org.thoughtcrime.securesms.conversationlist.model.MessageResult;
import org.thoughtcrime.securesms.search.MessageResult;
import org.thoughtcrime.securesms.crypto.DatabaseSessionLock;
import org.thoughtcrime.securesms.crypto.SecurityEvent;
import org.thoughtcrime.securesms.database.DatabaseFactory;
@ -2132,7 +2132,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
if (!result.getResults().isEmpty()) {
MessageResult messageResult = result.getResults().get(result.getPosition());
fragment.jumpToMessage(messageResult.messageRecipient.getId(), messageResult.receivedTimestampMs, searchViewModel::onMissingResult);
fragment.jumpToMessage(messageResult.getMessageRecipient().getId(), messageResult.getReceivedTimestampMs(), searchViewModel::onMissingResult);
}
searchNav.setData(result.getPosition(), result.getResults().size());

View file

@ -8,7 +8,7 @@ import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import org.signal.core.util.ThreadUtil;
import org.thoughtcrime.securesms.conversationlist.model.MessageResult;
import org.thoughtcrime.securesms.search.MessageResult;
import org.thoughtcrime.securesms.database.CursorList;
import org.thoughtcrime.securesms.search.SearchRepository;
import org.thoughtcrime.securesms.util.Debouncer;

View file

@ -91,8 +91,8 @@ import org.thoughtcrime.securesms.components.reminder.UnauthorizedReminder;
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
import org.thoughtcrime.securesms.conversation.ConversationFragment;
import org.thoughtcrime.securesms.conversationlist.model.Conversation;
import org.thoughtcrime.securesms.conversationlist.model.MessageResult;
import org.thoughtcrime.securesms.conversationlist.model.SearchResult;
import org.thoughtcrime.securesms.search.MessageResult;
import org.thoughtcrime.securesms.search.SearchResult;
import org.thoughtcrime.securesms.conversationlist.model.UnreadPayments;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MessageDatabase.MarkedMessageInfo;
@ -400,12 +400,12 @@ public class ConversationListFragment extends MainFragment implements ActionMode
@Override
public void onMessageClicked(@NonNull MessageResult message) {
SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> {
int startingPosition = DatabaseFactory.getMmsSmsDatabase(getContext()).getMessagePositionInConversation(message.threadId, message.receivedTimestampMs);
int startingPosition = DatabaseFactory.getMmsSmsDatabase(getContext()).getMessagePositionInConversation(message.getThreadId(), message.getReceivedTimestampMs());
return Math.max(0, startingPosition);
}, startingPosition -> {
hideKeyboard();
getNavigator().goToConversation(message.conversationRecipient.getId(),
message.threadId,
getNavigator().goToConversation(message.getConversationRecipient().getId(),
message.getThreadId(),
ThreadDatabase.DistributionTypes.DEFAULT,
startingPosition);
});
@ -481,7 +481,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
public void onSearchTextChange(String text) {
String trimmed = text.trim();
viewModel.updateQuery(trimmed);
viewModel.onSearchQueryUpdated(trimmed);
if (trimmed.length() > 0) {
if (activeAdapter != searchAdapter) {

View file

@ -48,7 +48,7 @@ import org.thoughtcrime.securesms.components.DeliveryStatusView;
import org.thoughtcrime.securesms.components.FromTextView;
import org.thoughtcrime.securesms.components.ThumbnailView;
import org.thoughtcrime.securesms.components.TypingIndicatorView;
import org.thoughtcrime.securesms.conversationlist.model.MessageResult;
import org.thoughtcrime.securesms.search.MessageResult;
import org.thoughtcrime.securesms.database.MmsSmsColumns;
import org.thoughtcrime.securesms.database.SmsDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase;
@ -237,7 +237,7 @@ public final class ConversationListItem extends ConstraintLayout
@NonNull Locale locale,
@Nullable String highlightSubstring)
{
observeRecipient(messageResult.conversationRecipient.live());
observeRecipient(messageResult.getConversationRecipient().live());
observeDisplayBody(null);
setSubjectViewText(null);
@ -245,8 +245,8 @@ public final class ConversationListItem extends ConstraintLayout
this.glideRequests = glideRequests;
fromView.setText(recipient.get(), true);
setSubjectViewText(SearchUtil.getHighlightedSpan(locale, () -> new StyleSpan(Typeface.BOLD), messageResult.bodySnippet, highlightSubstring));
dateView.setText(DateUtils.getBriefRelativeTimeSpanString(getContext(), locale, messageResult.receivedTimestampMs));
setSubjectViewText(SearchUtil.getHighlightedSpan(locale, () -> new StyleSpan(Typeface.BOLD), messageResult.getBodySnippet(), highlightSubstring));
dateView.setText(DateUtils.getBriefRelativeTimeSpanString(getContext(), locale, messageResult.getReceivedTimestampMs()));
archivedView.setVisibility(GONE);
unreadIndicator.setVisibility(GONE);
deliveryStatusIndicator.setNone();

View file

@ -10,8 +10,8 @@ import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.conversationlist.model.MessageResult;
import org.thoughtcrime.securesms.conversationlist.model.SearchResult;
import org.thoughtcrime.securesms.search.MessageResult;
import org.thoughtcrime.securesms.search.SearchResult;
import org.thoughtcrime.securesms.database.model.ThreadRecord;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.recipients.Recipient;

View file

@ -9,13 +9,12 @@ import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.logging.Log;
import org.signal.paging.PagedData;
import org.signal.paging.PagingConfig;
import org.signal.paging.PagingController;
import org.thoughtcrime.securesms.conversationlist.model.Conversation;
import org.thoughtcrime.securesms.conversationlist.model.SearchResult;
import org.thoughtcrime.securesms.search.SearchResult;
import org.thoughtcrime.securesms.conversationlist.model.UnreadPayments;
import org.thoughtcrime.securesms.conversationlist.model.UnreadPaymentsLiveData;
import org.thoughtcrime.securesms.database.DatabaseFactory;
@ -47,15 +46,17 @@ class ConversationListViewModel extends ViewModel {
private final LiveData<Boolean> hasNoConversations;
private final SearchRepository searchRepository;
private final MegaphoneRepository megaphoneRepository;
private final Debouncer searchDebouncer;
private final Debouncer messageSearchDebouncer;
private final Debouncer contactSearchDebouncer;
private final ThrottledDebouncer updateDebouncer;
private final DatabaseObserver.Observer observer;
private final Invalidator invalidator;
private final UnreadPaymentsLiveData unreadPaymentsLiveData;
private final UnreadPaymentsRepository unreadPaymentsRepository;
private String lastQuery;
private int pinnedCount;
private String activeQuery;
private SearchResult activeSearchResult;
private int pinnedCount;
private ConversationListViewModel(@NonNull Application application, @NonNull SearchRepository searchRepository, boolean isArchived) {
this.megaphone = new MutableLiveData<>();
@ -63,19 +64,21 @@ class ConversationListViewModel extends ViewModel {
this.searchRepository = searchRepository;
this.megaphoneRepository = ApplicationDependencies.getMegaphoneRepository();
this.unreadPaymentsRepository = new UnreadPaymentsRepository();
this.searchDebouncer = new Debouncer(300);
this.messageSearchDebouncer = new Debouncer(500);
this.contactSearchDebouncer = new Debouncer(100);
this.updateDebouncer = new ThrottledDebouncer(500);
this.activeSearchResult = SearchResult.EMPTY;
this.invalidator = new Invalidator();
this.pagedData = PagedData.create(ConversationListDataSource.create(application, isArchived),
new PagingConfig.Builder()
.setPageSize(15)
.setBufferPages(2)
.build());
.setPageSize(15)
.setBufferPages(2)
.build());
this.unreadPaymentsLiveData = new UnreadPaymentsLiveData();
this.observer = () -> {
updateDebouncer.publish(() -> {
if (!TextUtils.isEmpty(getLastQuery())) {
searchRepository.query(getLastQuery(), searchResult::postValue);
if (!TextUtils.isEmpty(activeQuery)) {
onSearchQueryUpdated(activeQuery);
}
pagedData.getController().onDataInvalidated();
});
@ -154,25 +157,57 @@ class ConversationListViewModel extends ViewModel {
unreadPaymentsRepository.markAllPaymentsSeen();
}
void updateQuery(String query) {
lastQuery = query;
searchDebouncer.publish(() -> searchRepository.query(query, result -> {
ThreadUtil.runOnMain(() -> {
if (query.equals(lastQuery)) {
searchResult.setValue(result);
}
});
}));
}
void onSearchQueryUpdated(String query) {
activeQuery = query;
private @NonNull String getLastQuery() {
return lastQuery == null ? "" : lastQuery;
contactSearchDebouncer.publish(() -> {
searchRepository.queryThreads(query, result -> {
if (!result.getQuery().equals(activeQuery)) {
return;
}
if (!activeSearchResult.getQuery().equals(activeQuery)) {
activeSearchResult = SearchResult.EMPTY;
}
activeSearchResult = activeSearchResult.merge(result);
searchResult.postValue(activeSearchResult);
});
searchRepository.queryContacts(query, result -> {
if (!result.getQuery().equals(activeQuery)) {
return;
}
if (!activeSearchResult.getQuery().equals(activeQuery)) {
activeSearchResult = SearchResult.EMPTY;
}
activeSearchResult = activeSearchResult.merge(result);
searchResult.postValue(activeSearchResult);
});
});
messageSearchDebouncer.publish(() -> {
searchRepository.queryMessages(query, result -> {
if (!result.getQuery().equals(activeQuery)) {
return;
}
if (!activeSearchResult.getQuery().equals(activeQuery)) {
activeSearchResult = SearchResult.EMPTY;
}
activeSearchResult = activeSearchResult.merge(result);
searchResult.postValue(activeSearchResult);
});
});
}
@Override
protected void onCleared() {
invalidator.invalidate();
searchDebouncer.clear();
messageSearchDebouncer.clear();
updateDebouncer.clear();
ApplicationDependencies.getDatabaseObserver().unregisterObserver(observer);
}

View file

@ -1,39 +0,0 @@
package org.thoughtcrime.securesms.conversationlist.model;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.recipients.Recipient;
/**
* Represents a search result for a message.
*/
public class MessageResult {
public final Recipient conversationRecipient;
public final Recipient messageRecipient;
public final String body;
public final String bodySnippet;
public final long threadId;
public final long messageId;
public final long receivedTimestampMs;
public final boolean isMms;
public MessageResult(@NonNull Recipient conversationRecipient,
@NonNull Recipient messageRecipient,
@NonNull String body,
@NonNull String bodySnippet,
long threadId,
long messageId,
long receivedTimestampMs,
boolean isMms)
{
this.conversationRecipient = conversationRecipient;
this.messageRecipient = messageRecipient;
this.body = body;
this.bodySnippet = bodySnippet;
this.threadId = threadId;
this.messageId = messageId;
this.receivedTimestampMs = receivedTimestampMs;
this.isMms = isMms;
}
}

View file

@ -1,58 +0,0 @@
package org.thoughtcrime.securesms.conversationlist.model;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.database.model.ThreadRecord;
import org.thoughtcrime.securesms.recipients.Recipient;
import java.util.Collections;
import java.util.List;
/**
* Represents an all-encompassing search result that can contain various result for different
* subcategories.
*/
public class SearchResult {
public static final SearchResult EMPTY = new SearchResult("", Collections.emptyList(), Collections.emptyList(), Collections.emptyList());
private final String query;
private final List<Recipient> contacts;
private final List<ThreadRecord> conversations;
private final List<MessageResult> messages;
public SearchResult(@NonNull String query,
@NonNull List<Recipient> contacts,
@NonNull List<ThreadRecord> conversations,
@NonNull List<MessageResult> messages)
{
this.query = query;
this.contacts = contacts;
this.conversations = conversations;
this.messages = messages;
}
public List<Recipient> getContacts() {
return contacts;
}
public List<ThreadRecord> getConversations() {
return conversations;
}
public List<MessageResult> getMessages() {
return messages;
}
public String getQuery() {
return query;
}
public int size() {
return contacts.size() + conversations.size() + messages.size();
}
public boolean isEmpty() {
return size() == 0;
}
}

View file

@ -0,0 +1,5 @@
package org.thoughtcrime.securesms.search
import org.thoughtcrime.securesms.recipients.Recipient
data class ContactSearchResult(val results: List<Recipient>, val query: String)

View file

@ -0,0 +1,17 @@
package org.thoughtcrime.securesms.search
import org.thoughtcrime.securesms.recipients.Recipient
/**
* Represents a search result for a message.
*/
data class MessageResult(
val conversationRecipient: Recipient,
val messageRecipient: Recipient,
val body: String,
val bodySnippet: String,
val threadId: Long,
val messageId: Long,
val receivedTimestampMs: Long,
val isMms: Boolean
)

View file

@ -0,0 +1,3 @@
package org.thoughtcrime.securesms.search
data class MessageSearchResult(val results: List<MessageResult>, val query: String)

View file

@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.search;
import android.content.Context;
import android.database.Cursor;
import android.database.DatabaseUtils;
import android.database.MergeCursor;
import android.text.TextUtils;
@ -13,12 +12,11 @@ import com.annimon.stream.Stream;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.contacts.ContactAccessor;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.contacts.ContactRepository;
import org.thoughtcrime.securesms.conversationlist.model.MessageResult;
import org.thoughtcrime.securesms.conversationlist.model.SearchResult;
import org.thoughtcrime.securesms.database.CursorList;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.GroupDatabase;
import org.thoughtcrime.securesms.database.MentionDatabase;
import org.thoughtcrime.securesms.database.MentionUtil;
import org.thoughtcrime.securesms.database.MessageDatabase;
@ -35,18 +33,19 @@ import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.CursorUtil;
import org.thoughtcrime.securesms.util.FtsUtil;
import org.thoughtcrime.securesms.util.Util;
import org.signal.core.util.concurrent.LatestPrioritizedSerialExecutor;
import org.thoughtcrime.securesms.util.concurrent.SerialExecutor;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Future;
import java.util.function.Consumer;
import static org.thoughtcrime.securesms.database.SearchDatabase.SNIPPET_WRAP;
@ -61,13 +60,13 @@ public class SearchRepository {
private final SearchDatabase searchDatabase;
private final ContactRepository contactRepository;
private final ThreadDatabase threadDatabase;
private final ContactAccessor contactAccessor;
private final Executor serialExecutor;
private final ExecutorService parallelExecutor;
private final RecipientDatabase recipientDatabase;
private final MentionDatabase mentionDatabase;
private final MessageDatabase mmsDatabase;
private final LatestPrioritizedSerialExecutor searchExecutor;
private final Executor serialExecutor;
public SearchRepository() {
this.context = ApplicationDependencies.getApplication().getApplicationContext();
this.searchDatabase = DatabaseFactory.getSearchDatabase(context);
@ -76,36 +75,44 @@ public class SearchRepository {
this.mentionDatabase = DatabaseFactory.getMentionDatabase(context);
this.mmsDatabase = DatabaseFactory.getMmsDatabase(context);
this.contactRepository = new ContactRepository(context);
this.contactAccessor = ContactAccessor.getInstance();
this.serialExecutor = SignalExecutors.SERIAL;
this.parallelExecutor = SignalExecutors.BOUNDED;
this.searchExecutor = new LatestPrioritizedSerialExecutor(SignalExecutors.BOUNDED);
this.serialExecutor = new SerialExecutor(SignalExecutors.BOUNDED);
}
public void query(@NonNull String query, @NonNull Callback<SearchResult> callback) {
if (TextUtils.isEmpty(query)) {
callback.onResult(SearchResult.EMPTY);
return;
}
public void queryThreads(@NonNull String query, @NonNull Consumer<ThreadSearchResult> callback) {
searchExecutor.execute(2, () -> {
long start = System.currentTimeMillis();
List<ThreadRecord> result = queryConversations(query);
serialExecutor.execute(() -> {
Log.d(TAG, "[threads] Search took " + (System.currentTimeMillis() - start) + " ms");
callback.accept(new ThreadSearchResult(result, query));
});
}
public void queryContacts(@NonNull String query, @NonNull Consumer<ContactSearchResult> callback) {
searchExecutor.execute(1, () -> {
long start = System.currentTimeMillis();
List<Recipient> result = queryContacts(query);
Log.d(TAG, "[contacts] Search took " + (System.currentTimeMillis() - start) + " ms");
callback.accept(new ContactSearchResult(result, query));
});
}
public void queryMessages(@NonNull String query, @NonNull Consumer<MessageSearchResult> callback) {
searchExecutor.execute(0, () -> {
long start = System.currentTimeMillis();
String cleanQuery = FtsUtil.sanitize(query);
Future<List<Recipient>> contacts = parallelExecutor.submit(() -> queryContacts(cleanQuery));
Future<List<ThreadRecord>> conversations = parallelExecutor.submit(() -> queryConversations(cleanQuery));
Future<List<MessageResult>> messages = parallelExecutor.submit(() -> queryMessages(cleanQuery));
Future<List<MessageResult>> mentionMessages = parallelExecutor.submit(() -> queryMentions(sanitizeQueryAsTokens(query)));
List<MessageResult> messages = queryMessages(cleanQuery);
List<MessageResult> mentionMessages = queryMentions(sanitizeQueryAsTokens(query));
List<MessageResult> combined = mergeMessagesAndMentions(messages, mentionMessages);
try {
long startTime = System.currentTimeMillis();
SearchResult result = new SearchResult(cleanQuery, contacts.get(), conversations.get(), mergeMessagesAndMentions(messages.get(), mentionMessages.get()));
Log.d(TAG, "[messages] Search took " + (System.currentTimeMillis() - start) + " ms");
Log.d(TAG, "Total time: " + (System.currentTimeMillis() - startTime) + " ms");
callback.onResult(result);
} catch (ExecutionException | InterruptedException e) {
Log.w(TAG, e);
callback.onResult(SearchResult.EMPTY);
}
callback.accept(new MessageSearchResult(combined, query));
});
}
@ -127,6 +134,10 @@ public class SearchRepository {
}
private List<Recipient> queryContacts(String query) {
if (Util.isEmpty(query)) {
return Collections.emptyList();
}
Cursor contacts = null;
try {
@ -144,15 +155,39 @@ public class SearchRepository {
}
private @NonNull List<ThreadRecord> queryConversations(@NonNull String query) {
List<String> numbers = contactAccessor.getNumbersForThreadSearchFilter(context, query);
List<RecipientId> recipientIds = Stream.of(numbers).map(number -> Recipient.external(context, number)).map(Recipient::getId).toList();
if (Util.isEmpty(query)) {
return Collections.emptyList();
}
try (Cursor cursor = threadDatabase.getFilteredConversationList(recipientIds)) {
Set<RecipientId> recipientIds = new LinkedHashSet<>();
try (Cursor cursor = DatabaseFactory.getRecipientDatabase(context).queryAllContacts(query)) {
while (cursor != null && cursor.moveToNext()) {
recipientIds.add(RecipientId.from(CursorUtil.requireString(cursor, RecipientDatabase.ID)));
}
}
GroupDatabase.GroupRecord record;
try (GroupDatabase.Reader reader = DatabaseFactory.getGroupDatabase(context).getGroupsFilteredByTitle(query, true, false)) {
while ((record = reader.getNext()) != null) {
recipientIds.add(record.getRecipientId());
}
}
if (context.getString(R.string.note_to_self).toLowerCase().contains(query.toLowerCase())) {
recipientIds.add(Recipient.self().getId());
}
try (Cursor cursor = threadDatabase.getFilteredConversationList(new ArrayList<>(recipientIds))) {
return readToList(cursor, new ThreadModelBuilder(threadDatabase));
}
}
private @NonNull List<MessageResult> queryMessages(@NonNull String query) {
if (Util.isEmpty(query)) {
return Collections.emptyList();
}
List<MessageResult> results;
try (Cursor cursor = searchDatabase.queryMessages(query)) {
results = readToList(cursor, new MessageModelBuilder());
@ -160,8 +195,8 @@ public class SearchRepository {
List<Long> messageIds = new LinkedList<>();
for (MessageResult result : results) {
if (result.isMms) {
messageIds.add(result.messageId);
if (result.isMms()) {
messageIds.add(result.getMessageId());
}
}
@ -176,15 +211,15 @@ public class SearchRepository {
List<MessageResult> updatedResults = new ArrayList<>(results.size());
for (MessageResult result : results) {
if (result.isMms && mentions.containsKey(result.messageId)) {
List<Mention> messageMentions = mentions.get(result.messageId);
if (result.isMms() && mentions.containsKey(result.getMessageId())) {
List<Mention> messageMentions = mentions.get(result.getMessageId());
//noinspection ConstantConditions
String updatedBody = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, result.body, messageMentions).getBody().toString();
String updatedSnippet = updateSnippetWithDisplayNames(result.body, result.bodySnippet, messageMentions);
String updatedBody = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, result.getBody(), messageMentions).getBody().toString();
String updatedSnippet = updateSnippetWithDisplayNames(result.getBody(), result.getBodySnippet(), messageMentions);
//noinspection ConstantConditions
updatedResults.add(new MessageResult(result.conversationRecipient, result.messageRecipient, updatedBody, updatedSnippet, result.threadId, result.messageId, result.receivedTimestampMs, result.isMms));
updatedResults.add(new MessageResult(result.getConversationRecipient(), result.getMessageRecipient(), updatedBody, updatedSnippet, result.getThreadId(), result.getMessageId(), result.getReceivedTimestampMs(), result.isMms()));
} else {
updatedResults.add(result);
}
@ -345,18 +380,18 @@ public class SearchRepository {
List<MessageResult> combined = new ArrayList<>(messages.size() + mentionMessages.size());
for (MessageResult result : messages) {
combined.add(result);
if (result.isMms) {
includedMmsMessages.add(result.messageId);
if (result.isMms()) {
includedMmsMessages.add(result.getMessageId());
}
}
for (MessageResult result : mentionMessages) {
if (!includedMmsMessages.contains(result.messageId)) {
if (!includedMmsMessages.contains(result.getMessageId())) {
combined.add(result);
}
}
Collections.sort(combined, Collections.reverseOrder((left, right) -> Long.compare(left.receivedTimestampMs, right.receivedTimestampMs)));
Collections.sort(combined, Collections.reverseOrder((left, right) -> Long.compare(left.getReceivedTimestampMs(), right.getReceivedTimestampMs())));
return combined;
}

View file

@ -0,0 +1,39 @@
package org.thoughtcrime.securesms.search
import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.recipients.Recipient
/**
* Represents an all-encompassing search result that can contain various result for different
* subcategories.
*/
data class SearchResult(
val query: String,
val contacts: List<Recipient>,
val conversations: List<ThreadRecord>,
val messages: List<MessageResult>
) {
fun size(): Int {
return contacts.size + conversations.size + messages.size
}
val isEmpty: Boolean
get() = size() == 0
fun merge(result: ContactSearchResult): SearchResult {
return this.copy(contacts = result.results, query = result.query)
}
fun merge(result: ThreadSearchResult): SearchResult {
return this.copy(conversations = result.results, query = result.query)
}
fun merge(result: MessageSearchResult): SearchResult {
return this.copy(messages = result.results, query = result.query)
}
companion object {
@JvmField
val EMPTY = SearchResult("", emptyList(), emptyList(), emptyList())
}
}

View file

@ -0,0 +1,5 @@
package org.thoughtcrime.securesms.search
import org.thoughtcrime.securesms.database.model.ThreadRecord
data class ThreadSearchResult(val results: List<ThreadRecord>, val query: String)

View file

@ -10,9 +10,11 @@ android {
defaultConfig {
minSdkVersion MINIMUM_SDK
targetSdkVersion TARGET_SDK
multiDexEnabled true
}
compileOptions {
coreLibraryDesugaringEnabled true
sourceCompatibility JAVA_VERSION
targetCompatibility JAVA_VERSION
}
@ -40,9 +42,12 @@ protobuf {
dependencies {
lintChecks project(':lintchecks')
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'
api 'androidx.annotation:annotation:1.1.0'
implementation 'com.google.protobuf:protobuf-javalite:3.10.0'
testImplementation 'junit:junit:4.13.1'
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.mockito:mockito-core:2.23.4'
}

View file

@ -0,0 +1,87 @@
package org.signal.core.util.concurrent;
import androidx.annotation.NonNull;
import java.util.Iterator;
import java.util.List;
import java.util.PriorityQueue;
import java.util.Queue;
import java.util.concurrent.Executor;
import java.util.stream.Collectors;
/**
* A serial executor that will order pending tasks by a specified priority, and will only keep a single task of a given priority, preferring the latest.
*
* So imagine a world where the following tasks were all enqueued (meaning they're all waiting to be executed):
*
* execute(0, runnableA);
* execute(3, runnableC1);
* execute(3, runnableC2);
* execute(2, runnableB);
*
* You'd expect the execution order to be:
* - runnableC2
* - runnableB
* - runnableA
*
* (We order by priority, and C1 was replaced by C2)
*/
public final class LatestPrioritizedSerialExecutor {
private final Queue<PriorityRunnable> tasks;
private final Executor executor;
private Runnable active;
public LatestPrioritizedSerialExecutor(@NonNull Executor executor) {
this.executor = executor;
this.tasks = new PriorityQueue<>();
}
/**
* Execute with a priority. Higher priorities are executed first.
*/
public synchronized void execute(int priority, @NonNull Runnable r) {
Iterator<PriorityRunnable> iterator = tasks.iterator();
while (iterator.hasNext()) {
if (iterator.next().getPriority() == priority) {
iterator.remove();
}
}
tasks.offer(new PriorityRunnable(priority) {
@Override
public void run() {
try {
r.run();
} finally {
scheduleNext();
}
}
});
if (active == null) {
scheduleNext();
}
}
private synchronized void scheduleNext() {
if ((active = tasks.poll()) != null) {
executor.execute(active);
}
}
private abstract static class PriorityRunnable implements Runnable, Comparable<PriorityRunnable> {
private final int priority;
public PriorityRunnable(int priority) {
this.priority = priority;
}
public int getPriority() {
return priority;
}
@Override
public final int compareTo(PriorityRunnable other) {
return other.getPriority() - this.getPriority();
}
}
}

View file

@ -0,0 +1,94 @@
package org.signal.core.util.concurrent;
import org.junit.Test;
import java.util.LinkedList;
import java.util.Queue;
import java.util.concurrent.Executor;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
public final class LatestPrioritizedSerialExecutorTest {
@Test
public void execute_sortsInPriorityOrder() {
TestExecutor executor = new TestExecutor();
Runnable placeholder = new TestRunnable();
Runnable first = spy(new TestRunnable());
Runnable second = spy(new TestRunnable());
Runnable third = spy(new TestRunnable());
LatestPrioritizedSerialExecutor subject = new LatestPrioritizedSerialExecutor(executor);
subject.execute(0, placeholder); // The first thing we execute can't be sorted, so we put in this placeholder
subject.execute(1, third);
subject.execute(2, second);
subject.execute(3, first);
executor.next(); // Clear the placeholder task
executor.next();
verify(first).run();
executor.next();
verify(second).run();
executor.next();
verify(third).run();
}
@Test
public void execute_replacesDupes() {
TestExecutor executor = new TestExecutor();
Runnable placeholder = new TestRunnable();
Runnable firstReplaced = spy(new TestRunnable());
Runnable first = spy(new TestRunnable());
Runnable second = spy(new TestRunnable());
Runnable thirdReplaced = spy(new TestRunnable());
Runnable third = spy(new TestRunnable());
LatestPrioritizedSerialExecutor subject = new LatestPrioritizedSerialExecutor(executor);
subject.execute(0, placeholder); // The first thing we execute can't be sorted, so we put in this placeholder
subject.execute(1, thirdReplaced);
subject.execute(1, third);
subject.execute(2, second);
subject.execute(3, firstReplaced);
subject.execute(3, first);
executor.next(); // Clear the placeholder task
executor.next();
verify(first).run();
executor.next();
verify(second).run();
executor.next();
verify(third).run();
verify(firstReplaced, never()).run();
verify(thirdReplaced, never()).run();
}
private static final class TestExecutor implements Executor {
private final Queue<Runnable> tasks = new LinkedList<>();
@Override
public void execute(Runnable command) {
tasks.add(command);
}
public void next() {
tasks.remove().run();
}
}
public static class TestRunnable implements Runnable {
@Override
public void run() { }
}
}