Implemented conversation search.

You can now search for messages within a specific conversation.
This commit is contained in:
Greyson Parrelli
2019-02-01 09:06:59 -08:00
parent 10631d7e71
commit 9f04c28bfd
27 changed files with 965 additions and 152 deletions

View File

@@ -49,6 +49,7 @@ import android.support.v4.view.MenuItemCompat;
import android.support.v4.view.WindowCompat;
import android.support.v7.app.ActionBar;
import android.support.v7.app.AlertDialog;
import android.support.v7.widget.SearchView;
import android.text.Editable;
import android.text.TextUtils;
import android.text.TextWatcher;
@@ -97,6 +98,7 @@ import org.thoughtcrime.securesms.color.MaterialColor;
import org.thoughtcrime.securesms.components.AnimatingToggle;
import org.thoughtcrime.securesms.components.AttachmentTypeSelector;
import org.thoughtcrime.securesms.components.ComposeText;
import org.thoughtcrime.securesms.components.ConversationSearchBottomBar;
import org.thoughtcrime.securesms.components.HidingLinearLayout;
import org.thoughtcrime.securesms.components.InputAwareLayout;
import org.thoughtcrime.securesms.components.InputPanel;
@@ -179,6 +181,7 @@ import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientFormattingException;
import org.thoughtcrime.securesms.recipients.RecipientModifiedListener;
import org.thoughtcrime.securesms.scribbles.ScribbleActivity;
import org.thoughtcrime.securesms.search.model.MessageResult;
import org.thoughtcrime.securesms.service.KeyCachingService;
import org.thoughtcrime.securesms.sms.MessageSender;
import org.thoughtcrime.securesms.sms.OutgoingEncryptedMessage;
@@ -235,7 +238,8 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
OnKeyboardShownListener,
AttachmentDrawerListener,
InputPanel.Listener,
InputPanel.MediaListener
InputPanel.MediaListener,
ConversationSearchBottomBar.EventListener
{
private static final String TAG = ConversationActivity.class.getSimpleName();
@@ -280,6 +284,8 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
private Stub<UnverifiedBannerView> unverifiedBannerView;
private Stub<GroupShareProfileView> groupShareProfileView;
private TypingStatusTextWatcher typingTextWatcher;
private ConversationSearchBottomBar searchNav;
private MenuItem searchViewItem;
private AttachmentTypeSelector attachmentTypeSelector;
private AttachmentManager attachmentManager;
@@ -290,7 +296,9 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
protected HidingLinearLayout inlineAttachmentToggle;
private QuickAttachmentDrawer quickAttachmentDrawer;
private InputPanel inputPanel;
private LinkPreviewViewModel linkPreviewViewModel;
private LinkPreviewViewModel linkPreviewViewModel;
private ConversationSearchViewModel searchViewModel;
private Recipient recipient;
private long threadId;
@@ -331,6 +339,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
initializeViews();
initializeResources();
initializeLinkPreviewObserver();
initializeSearchObserver();
initializeSecurity(false, isDefaultSms).addListener(new AssertedSuccessListener<Boolean>() {
@Override
public void onSuccess(Boolean result) {
@@ -643,6 +652,56 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
inflater.inflate(R.menu.conversation_add_to_contacts, menu);
}
searchViewItem = menu.findItem(R.id.menu_search);
SearchView searchView = (SearchView) searchViewItem.getActionView();
SearchView.OnQueryTextListener queryListener = new SearchView.OnQueryTextListener() {
@Override
public boolean onQueryTextSubmit(String query) {
searchViewModel.onQueryUpdated(query, threadId);
searchNav.showLoading();
fragment.onSearchQueryUpdated(query);
return true;
}
@Override
public boolean onQueryTextChange(String query) {
searchViewModel.onQueryUpdated(query, threadId);
searchNav.showLoading();
fragment.onSearchQueryUpdated(query);
return true;
}
};
searchViewItem.setOnActionExpandListener(new MenuItem.OnActionExpandListener() {
@Override
public boolean onMenuItemActionExpand(MenuItem item) {
searchView.setOnQueryTextListener(queryListener);
searchViewModel.onSearchOpened();
searchNav.setVisibility(View.VISIBLE);
searchNav.setData(0, 0);
inputPanel.setVisibility(View.GONE);
for (int i = 0; i < menu.size(); i++) {
if (!menu.getItem(i).equals(searchViewItem)) {
menu.getItem(i).setVisible(false);
}
}
return true;
}
@Override
public boolean onMenuItemActionCollapse(MenuItem item) {
searchView.setOnQueryTextListener(null);
searchViewModel.onSearchClosed();
searchNav.setVisibility(View.GONE);
inputPanel.setVisibility(View.VISIBLE);
fragment.onSearchQueryUpdated(null);
invalidateOptionsMenu();
return true;
}
});
super.onPrepareOptionsMenu(menu);
return true;
}
@@ -655,6 +714,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
case R.id.menu_call_insecure: handleDial(getRecipient(), false); return true;
case R.id.menu_view_media: handleViewMedia(); return true;
case R.id.menu_add_shortcut: handleAddShortcut(); return true;
case R.id.menu_search: handleSearch(); return true;
case R.id.menu_add_to_contacts: handleAddToContacts(); return true;
case R.id.menu_reset_secure_session: handleResetSecureSession(); return true;
case R.id.menu_group_recipients: handleDisplayGroupRecipients(); return true;
@@ -920,6 +980,10 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
}.execute();
}
private void handleSearch() {
searchViewModel.onSearchOpened();
}
private void handleLeavePushGroup() {
if (getRecipient() == null) {
Toast.makeText(this, getString(R.string.ConversationActivity_invalid_recipient),
@@ -1438,6 +1502,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
quickAttachmentToggle = ViewUtil.findById(this, R.id.quick_attachment_toggle);
inlineAttachmentToggle = ViewUtil.findById(this, R.id.inline_attachment_container);
inputPanel = ViewUtil.findById(this, R.id.bottom_panel);
searchNav = ViewUtil.findById(this, R.id.conversation_search_nav);
ImageButton quickCameraToggle = ViewUtil.findById(this, R.id.quick_camera_toggle);
ImageButton inlineAttachmentButton = ViewUtil.findById(this, R.id.inline_attachment_button);
@@ -1489,6 +1554,8 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
quickCameraToggle.setEnabled(false);
}
searchNav.setEventListener(this);
inlineAttachmentButton.setOnClickListener(v -> handleAddAttachment());
}
@@ -1544,6 +1611,30 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
});
}
private void initializeSearchObserver() {
searchViewModel = ViewModelProviders.of(this).get(ConversationSearchViewModel.class);
searchViewModel.getSearchResults().observe(this, result -> {
if (result == null) return;
if (!result.getResults().isEmpty()) {
MessageResult messageResult = result.getResults().get(result.getPosition());
fragment.jumpToMessage(messageResult.messageRecipient.getAddress(), messageResult.receivedTimestampMs, searchViewModel::onMissingResult);
}
searchNav.setData(result.getPosition(), result.getResults().size());
});
}
@Override
public void onSearchMoveUpPressed() {
searchViewModel.onMoveUp();
}
@Override
public void onSearchMoveDownPressed() {
searchViewModel.onMoveDown();
}
private void initializeProfiles() {
if (!isSecureText) {
@@ -1569,7 +1660,10 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
updateReminders(recipient.hasSeenInviteReminder());
updateDefaultSubscriptionId(recipient.getDefaultSubscriptionId());
initializeSecurity(isSecureText, isDefaultSms);
invalidateOptionsMenu();
if (!searchViewItem.isActionViewExpanded()) {
invalidateOptionsMenu();
}
});
}
@@ -2445,6 +2539,11 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
}
}
@Override
public void onMessageActionToolbarOpened() {
searchViewItem.collapseActionView();
}
@Override
public void onAttachmentChanged() {
handleSecurityChange(isSecureText, isDefaultSms);

View File

@@ -105,6 +105,7 @@ public class ConversationAdapter <V extends View & BindableConversationItem>
private final @NonNull MessageDigest digest;
private MessageRecord recordToPulseHighlight;
private String searchQuery;
protected static class ViewHolder extends RecyclerView.ViewHolder {
public <V extends View & BindableConversationItem> ViewHolder(final @NonNull V itemView) {
@@ -205,6 +206,7 @@ public class ConversationAdapter <V extends View & BindableConversationItem>
locale,
batchSelected,
recipient,
searchQuery,
messageRecord == recordToPulseHighlight);
if (messageRecord == recordToPulseHighlight) {
@@ -363,6 +365,11 @@ public class ConversationAdapter <V extends View & BindableConversationItem>
}
}
public void onSearchQueryUpdated(@Nullable String query) {
this.searchQuery = query;
notifyDataSetChanged();
}
private boolean hasAudio(MessageRecord messageRecord) {
return messageRecord.isMms() && ((MmsMessageRecord)messageRecord).getSlideDeck().getAudioSlide() != null;
}

View File

@@ -27,6 +27,7 @@ import android.os.AsyncTask;
import android.os.Build;
import android.os.Bundle;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v4.app.ActivityCompat;
import android.support.v4.app.ActivityOptionsCompat;
import android.support.v4.app.Fragment;
@@ -49,6 +50,7 @@ import org.thoughtcrime.securesms.ShareActivity;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.components.ConversationTypingView;
import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager;
import org.thoughtcrime.securesms.database.Address;
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.logging.Log;
@@ -94,6 +96,7 @@ import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask;
import org.whispersystems.libsignal.util.guava.Optional;
@@ -126,6 +129,7 @@ public class ConversationFragment extends Fragment
private long lastSeen;
private int startingPosition;
private int previousOffset;
private int activeOffset;
private boolean firstLoad;
private long loaderStartTime;
private ActionMode actionMode;
@@ -631,9 +635,14 @@ public class ConversationFragment extends Fragment
if (loader.hasOffset()) {
adapter.setHeaderView(bottomLoadMoreView);
}
if (firstLoad || loader.hasOffset()) {
previousOffset = loader.getOffset();
}
activeOffset = loader.getOffset();
adapter.changeCursor(cursor);
int lastSeenPosition = adapter.findLastSeenPosition(lastSeen);
@@ -734,9 +743,42 @@ public class ConversationFragment extends Fragment
return firstVisiblePosition == 0 && list.getChildAt(0).getBottom() <= list.getHeight();
}
public void onSearchQueryUpdated(@Nullable String query) {
getListAdapter().onSearchQueryUpdated(query);
}
public void jumpToMessage(@NonNull Address author, long timestamp, @Nullable Runnable onMessageNotFound) {
SimpleTask.run(getLifecycle(), () -> {
return DatabaseFactory.getMmsSmsDatabase(getContext())
.getMessagePositionInConversation(threadId, timestamp, author);
}, p -> moveToMessagePosition(p, onMessageNotFound));
}
private void moveToMessagePosition(int position, @Nullable Runnable onMessageNotFound) {
Log.d(TAG, "Moving to message position: " + position + " activeOffset: " + activeOffset + " cursorCount: " + getListAdapter().getCursorCount());
if (position >= activeOffset && position >= 0 && position < getListAdapter().getCursorCount()) {
int offset = activeOffset > 0 ? activeOffset - 1 : 0;
list.scrollToPosition(position - offset);
getListAdapter().pulseHighlightItem(position - offset);
} else if (position < 0) {
Log.w(TAG, "Tried to navigate to message, but it wasn't found.");
if (onMessageNotFound != null) {
onMessageNotFound.run();
}
} else {
Log.i(TAG, "Message was outside of the loaded range. Need to restart the loader.");
firstLoad = true;
startingPosition = position;
getLoaderManager().restartLoader(0, Bundle.EMPTY, ConversationFragment.this);
}
}
public interface ConversationFragmentListener {
void setThreadId(long threadId);
void handleReplyMessage(MessageRecord messageRecord);
void onMessageActionToolbarOpened();
}
private class ConversationScrollListener extends OnScrollListener {
@@ -848,41 +890,14 @@ public class ConversationFragment extends Fragment
return;
}
new AsyncTask<Void, Void, Integer>() {
@Override
protected Integer doInBackground(Void... voids) {
if (getActivity() == null || getActivity().isFinishing()) {
Log.w(TAG, "Task to retrieve quote position started after the fragment was detached.");
return 0;
}
return DatabaseFactory.getMmsSmsDatabase(getContext())
.getQuotedMessagePosition(threadId,
messageRecord.getQuote().getId(),
messageRecord.getQuote().getAuthor());
}
@Override
protected void onPostExecute(Integer position) {
if (getActivity() == null || getActivity().isFinishing()) {
Log.w(TAG, "Task to retrieve quote position finished after the fragment was detached.");
return;
}
if (position >= 0 && position < getListAdapter().getItemCount()) {
list.scrollToPosition(position);
getListAdapter().pulseHighlightItem(position);
} else if (position < 0) {
Log.w(TAG, "Tried to navigate to quoted message, but it was deleted.");
Toast.makeText(getContext(), R.string.ConversationFragment_quoted_message_no_longer_available, Toast.LENGTH_SHORT).show();
} else {
Log.i(TAG, "Quoted message was outside of the loaded range. Need to restart the loader.");
firstLoad = true;
startingPosition = position;
getLoaderManager().restartLoader(0, Bundle.EMPTY, ConversationFragment.this);
}
}
}.execute();
SimpleTask.run(getLifecycle(), () -> {
return DatabaseFactory.getMmsSmsDatabase(getContext())
.getQuotedMessagePosition(threadId,
messageRecord.getQuote().getId(),
messageRecord.getQuote().getAuthor());
}, p -> moveToMessagePosition(p, () -> {
Toast.makeText(getContext(), R.string.ConversationFragment_quoted_message_no_longer_available, Toast.LENGTH_SHORT).show();
}));
}
@Override
@@ -965,6 +980,7 @@ public class ConversationFragment extends Fragment
}
setCorrectMenuVisibility(menu);
listener.onMessageActionToolbarOpened();
return true;
}

View File

@@ -28,9 +28,12 @@ import android.support.annotation.DimenRes;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v7.app.AlertDialog;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.style.BackgroundColorSpan;
import android.text.style.ForegroundColorSpan;
import android.text.style.URLSpan;
import android.text.util.Linkify;
import android.util.AttributeSet;
@@ -87,6 +90,7 @@ import org.thoughtcrime.securesms.util.DateUtils;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.LongClickCopySpan;
import org.thoughtcrime.securesms.util.LongClickMovementMethod;
import org.thoughtcrime.securesms.util.SearchUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
@@ -206,6 +210,7 @@ public class ConversationItem extends LinearLayout
@NonNull Locale locale,
@NonNull Set<MessageRecord> batchSelected,
@NonNull Recipient conversationRecipient,
@Nullable String searchQuery,
boolean pulseHighlight)
{
this.messageRecord = messageRecord;
@@ -223,7 +228,7 @@ public class ConversationItem extends LinearLayout
setMessageShape(messageRecord, previousMessageRecord, nextMessageRecord, groupThread);
setMediaAttributes(messageRecord, previousMessageRecord, nextMessageRecord, conversationRecipient, groupThread);
setInteractionState(messageRecord, pulseHighlight);
setBodyText(messageRecord);
setBodyText(messageRecord, searchQuery);
setBubbleState(messageRecord);
setStatusIcons(messageRecord);
setContactPhoto(recipient);
@@ -401,7 +406,7 @@ public class ConversationItem extends LinearLayout
return messageRecord.isMms() && !((MmsMessageRecord)messageRecord).getLinkPreviews().isEmpty();
}
private void setBodyText(MessageRecord messageRecord) {
private void setBodyText(MessageRecord messageRecord, @Nullable String searchQuery) {
bodyText.setClickable(false);
bodyText.setFocusable(false);
bodyText.setTextSize(TypedValue.COMPLEX_UNIT_SP, TextSecurePreferences.getMessageBodyTextSize(context));
@@ -409,7 +414,11 @@ public class ConversationItem extends LinearLayout
if (isCaptionlessMms(messageRecord)) {
bodyText.setVisibility(View.GONE);
} else {
bodyText.setText(linkifyMessageBody(messageRecord.getDisplayBody(), batchSelected.isEmpty()));
Spannable styledText = linkifyMessageBody(messageRecord.getDisplayBody(), batchSelected.isEmpty());
styledText = SearchUtil.getHighlightedSpan(locale, () -> new BackgroundColorSpan(Color.YELLOW), styledText, searchQuery);
styledText = SearchUtil.getHighlightedSpan(locale, () -> new ForegroundColorSpan(Color.BLACK), styledText, searchQuery);
bodyText.setText(styledText);
bodyText.setVisibility(View.VISIBLE);
}
}

View File

@@ -0,0 +1,147 @@
package org.thoughtcrime.securesms.conversation;
import android.app.Application;
import android.arch.lifecycle.AndroidViewModel;
import android.arch.lifecycle.LiveData;
import android.content.Context;
import android.os.AsyncTask;
import android.support.annotation.NonNull;
import org.thoughtcrime.securesms.contacts.ContactAccessor;
import org.thoughtcrime.securesms.database.CursorList;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.search.SearchRepository;
import org.thoughtcrime.securesms.search.model.MessageResult;
import org.thoughtcrime.securesms.util.CloseableLiveData;
import org.thoughtcrime.securesms.util.Debouncer;
import org.thoughtcrime.securesms.util.Util;
import java.io.Closeable;
import java.util.List;
public class ConversationSearchViewModel extends AndroidViewModel {
private final SearchRepository searchRepository;
private final CloseableLiveData<SearchResult> result;
private final Debouncer debouncer;
private boolean firstSearch;
private boolean searchOpen;
private String activeQuery;
private long activeThreadId;
public ConversationSearchViewModel(@NonNull Application application) {
super(application);
Context context = application.getApplicationContext();
result = new CloseableLiveData<>();
debouncer = new Debouncer(500);
searchRepository = new SearchRepository(context,
DatabaseFactory.getSearchDatabase(context),
DatabaseFactory.getContactsDatabase(context),
DatabaseFactory.getThreadDatabase(context),
ContactAccessor.getInstance(),
AsyncTask.THREAD_POOL_EXECUTOR);
}
LiveData<SearchResult> getSearchResults() {
return result;
}
void onQueryUpdated(@NonNull String query, long threadId) {
if (firstSearch && query.length() < 2) {
result.postValue(new SearchResult(CursorList.emptyList(), 0));
return;
}
if (query.equals(activeQuery)) {
return;
}
updateQuery(query, threadId);
}
void onMissingResult() {
if (activeQuery != null) {
updateQuery(activeQuery, activeThreadId);
}
}
void onMoveUp() {
debouncer.clear();
CursorList<MessageResult> messages = (CursorList<MessageResult>) result.getValue().getResults();
int position = Math.min(result.getValue().getPosition() + 1, messages.size() - 1);
result.setValue(new SearchResult(messages, position), false);
}
void onMoveDown() {
debouncer.clear();
CursorList<MessageResult> messages = (CursorList<MessageResult>) result.getValue().getResults();
int position = Math.max(result.getValue().getPosition() - 1, 0);
result.setValue(new SearchResult(messages, position), false);
}
void onSearchOpened() {
searchOpen = true;
firstSearch = true;
}
void onSearchClosed() {
searchOpen = false;
debouncer.clear();
result.close();
}
@Override
protected void onCleared() {
super.onCleared();
result.close();
}
private void updateQuery(@NonNull String query, long threadId) {
activeQuery = query;
activeThreadId = threadId;
debouncer.publish(() -> {
firstSearch = false;
searchRepository.query(query, threadId, messages -> {
Util.runOnMain(() -> {
if (searchOpen && query.equals(activeQuery)) {
result.setValue(new SearchResult(messages, 0));
} else {
messages.close();
}
});
});
});
}
static class SearchResult implements Closeable {
private final CursorList<MessageResult> results;
private final int position;
SearchResult(CursorList<MessageResult> results, int position) {
this.results = results;
this.position = position;
}
public List<MessageResult> getResults() {
return results;
}
public int getPosition() {
return position;
}
@Override
public void close() {
results.close();
}
}
}

View File

@@ -79,6 +79,7 @@ public class ConversationUpdateItem extends LinearLayout
@NonNull Locale locale,
@NonNull Set<MessageRecord> batchSelected,
@NonNull Recipient conversationRecipient,
@Nullable String searchQuery,
boolean pulseUpdate)
{
this.batchSelected = batchSelected;