diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactAccessor.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactAccessor.java index 81eca1d7b3..0d1e54ba03 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactAccessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactAccessor.java @@ -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 getNumbersForThreadSearchFilter(Context context, String constraint) { - LinkedList 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); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java index 137b6d2534..4aaeb4d19e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java @@ -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()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationSearchViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationSearchViewModel.java index 7769ddc694..6c6a53a4fb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationSearchViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationSearchViewModel.java @@ -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; 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 11253490c0..3025b82e6b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java index 7fefabb872..4d11afc488 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java @@ -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(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListSearchAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListSearchAdapter.java index bccbeb19f7..8753b908fd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListSearchAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListSearchAdapter.java @@ -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; diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.java index e74d9177b8..39dc7a0652 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.java @@ -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 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); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/model/MessageResult.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/model/MessageResult.java deleted file mode 100644 index 17fd7b55b4..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/model/MessageResult.java +++ /dev/null @@ -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; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/model/SearchResult.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/model/SearchResult.java deleted file mode 100644 index fcc8c0f564..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/model/SearchResult.java +++ /dev/null @@ -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 contacts; - private final List conversations; - private final List messages; - - public SearchResult(@NonNull String query, - @NonNull List contacts, - @NonNull List conversations, - @NonNull List messages) - { - this.query = query; - this.contacts = contacts; - this.conversations = conversations; - this.messages = messages; - } - - public List getContacts() { - return contacts; - } - - public List getConversations() { - return conversations; - } - - public List getMessages() { - return messages; - } - - public String getQuery() { - return query; - } - - public int size() { - return contacts.size() + conversations.size() + messages.size(); - } - - public boolean isEmpty() { - return size() == 0; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/search/ContactSearchResult.kt b/app/src/main/java/org/thoughtcrime/securesms/search/ContactSearchResult.kt new file mode 100644 index 0000000000..d5fdb10c64 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/search/ContactSearchResult.kt @@ -0,0 +1,5 @@ +package org.thoughtcrime.securesms.search + +import org.thoughtcrime.securesms.recipients.Recipient + +data class ContactSearchResult(val results: List, val query: String) diff --git a/app/src/main/java/org/thoughtcrime/securesms/search/MessageResult.kt b/app/src/main/java/org/thoughtcrime/securesms/search/MessageResult.kt new file mode 100644 index 0000000000..d5bb1c3815 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/search/MessageResult.kt @@ -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 +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/search/MessageSearchResult.kt b/app/src/main/java/org/thoughtcrime/securesms/search/MessageSearchResult.kt new file mode 100644 index 0000000000..81d0fe2126 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/search/MessageSearchResult.kt @@ -0,0 +1,3 @@ +package org.thoughtcrime.securesms.search + +data class MessageSearchResult(val results: List, val query: String) 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 57e6cbfae4..2a34940d93 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java @@ -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 callback) { - if (TextUtils.isEmpty(query)) { - callback.onResult(SearchResult.EMPTY); - return; - } + public void queryThreads(@NonNull String query, @NonNull Consumer callback) { + searchExecutor.execute(2, () -> { + long start = System.currentTimeMillis(); + List 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 callback) { + searchExecutor.execute(1, () -> { + long start = System.currentTimeMillis(); + List 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 callback) { + searchExecutor.execute(0, () -> { + long start = System.currentTimeMillis(); String cleanQuery = FtsUtil.sanitize(query); - Future> contacts = parallelExecutor.submit(() -> queryContacts(cleanQuery)); - Future> conversations = parallelExecutor.submit(() -> queryConversations(cleanQuery)); - Future> messages = parallelExecutor.submit(() -> queryMessages(cleanQuery)); - Future> mentionMessages = parallelExecutor.submit(() -> queryMentions(sanitizeQueryAsTokens(query))); + List messages = queryMessages(cleanQuery); + List mentionMessages = queryMentions(sanitizeQueryAsTokens(query)); + List 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 queryContacts(String query) { + if (Util.isEmpty(query)) { + return Collections.emptyList(); + } + Cursor contacts = null; try { @@ -144,15 +155,39 @@ public class SearchRepository { } private @NonNull List queryConversations(@NonNull String query) { - List numbers = contactAccessor.getNumbersForThreadSearchFilter(context, query); - List 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 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 queryMessages(@NonNull String query) { + if (Util.isEmpty(query)) { + return Collections.emptyList(); + } + List results; try (Cursor cursor = searchDatabase.queryMessages(query)) { results = readToList(cursor, new MessageModelBuilder()); @@ -160,8 +195,8 @@ public class SearchRepository { List 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 updatedResults = new ArrayList<>(results.size()); for (MessageResult result : results) { - if (result.isMms && mentions.containsKey(result.messageId)) { - List messageMentions = mentions.get(result.messageId); + if (result.isMms() && mentions.containsKey(result.getMessageId())) { + List 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 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; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/search/SearchResult.kt b/app/src/main/java/org/thoughtcrime/securesms/search/SearchResult.kt new file mode 100644 index 0000000000..b11b1f27f3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/search/SearchResult.kt @@ -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, + val conversations: List, + val messages: List +) { + 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()) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/search/ThreadSearchResult.kt b/app/src/main/java/org/thoughtcrime/securesms/search/ThreadSearchResult.kt new file mode 100644 index 0000000000..e50673189b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/search/ThreadSearchResult.kt @@ -0,0 +1,5 @@ +package org.thoughtcrime.securesms.search + +import org.thoughtcrime.securesms.database.model.ThreadRecord + +data class ThreadSearchResult(val results: List, val query: String) diff --git a/core-util/build.gradle b/core-util/build.gradle index 1355df8275..64a7e0c7bf 100644 --- a/core-util/build.gradle +++ b/core-util/build.gradle @@ -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' } diff --git a/core-util/src/main/java/org/signal/core/util/concurrent/LatestPrioritizedSerialExecutor.java b/core-util/src/main/java/org/signal/core/util/concurrent/LatestPrioritizedSerialExecutor.java new file mode 100644 index 0000000000..252c18bd2b --- /dev/null +++ b/core-util/src/main/java/org/signal/core/util/concurrent/LatestPrioritizedSerialExecutor.java @@ -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 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 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 { + 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(); + } + } +} \ No newline at end of file diff --git a/core-util/src/test/java/org/signal/core/util/concurrent/LatestPrioritizedSerialExecutorTest.java b/core-util/src/test/java/org/signal/core/util/concurrent/LatestPrioritizedSerialExecutorTest.java new file mode 100644 index 0000000000..f1541594aa --- /dev/null +++ b/core-util/src/test/java/org/signal/core/util/concurrent/LatestPrioritizedSerialExecutorTest.java @@ -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 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() { } + } +}