Implemented conversation search.

You can now search for messages within a specific conversation.
master
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

@ -54,7 +54,15 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
<include layout="@layout/conversation_input_panel"/>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<include layout="@layout/conversation_input_panel"/>
<include layout="@layout/conversation_search_nav" />
</FrameLayout>
<Button android:id="@+id/register_button"
android:layout_width="fill_parent"

View File

@ -0,0 +1,76 @@
<?xml version="1.0" encoding="utf-8"?>
<org.thoughtcrime.securesms.components.ConversationSearchBottomBar
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/conversation_search_nav"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?conversation_background"
android:visibility="gone"
tools:visibility="visible"
tools:parentTag="android.support.constraint.ConstraintLayout">
<TextView
android:id="@+id/conversation_search_position"
style="@style/Signal.Text.Body"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
android:layout_marginRight="16dp"
android:layout_marginBottom="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/conversation_search_up"
app:layout_constraintTop_toTopOf="parent"
tools:text="37 of 73" />
<ImageView
android:id="@+id/conversation_search_up"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:layout_marginRight="8dp"
android:layout_marginBottom="8dp"
android:background="?selectableItemBackgroundBorderless"
android:padding="8dp"
android:src="@drawable/ic_keyboard_arrow_up_white_36dp"
android:tint="@color/signal_primary"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/conversation_search_down"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/conversation_search_down"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:layout_marginRight="8dp"
android:layout_marginBottom="8dp"
android:background="?selectableItemBackgroundBorderless"
android:padding="8dp"
android:src="@drawable/ic_keyboard_arrow_down_white_24dp"
android:tint="@color/signal_primary"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.pnikosis.materialishprogress.ProgressWheel
android:id="@+id/conversation_search_progress_wheel"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_gravity="center"
android:layout_marginTop="0dp"
android:layout_marginBottom="0dp"
android:indeterminate="true"
android:padding="8dp"
android:background="?conversation_background"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:matProg_barColor="@color/core_grey_25"
app:matProg_progressIndeterminate="true" />
</org.thoughtcrime.securesms.components.ConversationSearchBottomBar>

View File

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item android:title="@string/conversation__menu_view_all_media"
android:id="@+id/menu_view_media" />
@ -7,6 +8,12 @@
<item android:title="@string/conversation__menu_conversation_settings"
android:id="@+id/menu_conversation_settings"/>
<item android:title="@string/SearchToolbar_search"
android:id="@+id/menu_search"
app:actionViewClass="android.support.v7.widget.SearchView"
app:showAsAction="collapseActionView"/>
<item android:title="@string/conversation__menu_add_shortcut"
android:id="@+id/menu_add_shortcut"/>

View File

@ -180,6 +180,8 @@
<string name="ConversationActivity_signal_cannot_sent_sms_mms_messages_because_it_is_not_your_default_sms_app">Signal cannot send SMS/MMS messages because it is not your default SMS app. Would you like to change this in your Android settings?</string>
<string name="ConversationActivity_yes">Yes</string>
<string name="ConversationActivity_no">No</string>
<string name="ConversationActivity_search_position">%1$d of %2$d</string>
<string name="ConversationActivity_no_results">No results</string>
<!-- ConversationAdapter -->
<plurals name="ConversationAdapter_n_unread_messages">

View File

@ -24,6 +24,7 @@ public interface BindableConversationItem extends Unbindable {
@NonNull Locale locale,
@NonNull Set<MessageRecord> batchSelected,
@NonNull Recipient recipients,
@Nullable String searchQuery,
boolean pulseHighlight);
MessageRecord getMessageRecord();

View File

@ -18,7 +18,6 @@ package org.thoughtcrime.securesms;
import android.content.Context;
import android.content.res.ColorStateList;
import android.graphics.Color;
import android.graphics.Typeface;
import android.graphics.drawable.RippleDrawable;
import android.os.Build.VERSION;
@ -32,11 +31,9 @@ import android.text.TextUtils;
import android.text.style.StyleSpan;
import android.util.AttributeSet;
import android.view.View;
import android.widget.ImageView;
import android.widget.RelativeLayout;
import android.widget.TextView;
import com.amulyakhare.textdrawable.TextDrawable;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.components.AlertView;
@ -51,6 +48,8 @@ import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientModifiedListener;
import org.thoughtcrime.securesms.search.model.MessageResult;
import org.thoughtcrime.securesms.util.DateUtils;
import org.thoughtcrime.securesms.util.SearchUtil;
import org.thoughtcrime.securesms.util.SearchUtil.StyleFactory;
import org.thoughtcrime.securesms.util.ThemeUtil;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
@ -60,8 +59,6 @@ import java.util.List;
import java.util.Locale;
import java.util.Set;
import static org.thoughtcrime.securesms.util.SpanUtil.color;
public class ConversationListItem extends RelativeLayout
implements RecipientModifiedListener,
BindableConversationListItem, Unbindable
@ -150,7 +147,7 @@ public class ConversationListItem extends RelativeLayout
this.recipient.addListener(this);
if (highlightSubstring != null) {
this.fromView.setText(getHighlightedSpan(locale, recipient.getName(), highlightSubstring));
this.fromView.setText(SearchUtil.getHighlightedSpan(locale, () -> new StyleSpan(Typeface.BOLD), recipient.getName(), highlightSubstring));
} else {
this.fromView.setText(recipient, unreadCount == 0);
}
@ -204,8 +201,8 @@ public class ConversationListItem extends RelativeLayout
this.recipient.addListener(this);
fromView.setText(getHighlightedSpan(locale, recipient.getName(), highlightSubstring));
subjectView.setText(getHighlightedSpan(locale, contact.getAddress().toPhoneString(), highlightSubstring));
fromView.setText(SearchUtil.getHighlightedSpan(locale, () -> new StyleSpan(Typeface.BOLD), recipient.getName(), highlightSubstring));
subjectView.setText(SearchUtil.getHighlightedSpan(locale, () -> new StyleSpan(Typeface.BOLD), contact.getAddress().toPhoneString(), highlightSubstring));
dateView.setText("");
archivedView.setVisibility(GONE);
unreadIndicator.setVisibility(GONE);
@ -224,13 +221,13 @@ public class ConversationListItem extends RelativeLayout
@Nullable String highlightSubstring)
{
this.selectedThreads = Collections.emptySet();
this.recipient = messageResult.recipient;
this.recipient = messageResult.conversationRecipient;
this.glideRequests = glideRequests;
this.recipient.addListener(this);
fromView.setText(recipient, true);
subjectView.setText(getHighlightedSpan(locale, messageResult.bodySnippet, highlightSubstring));
subjectView.setText(SearchUtil.getHighlightedSpan(locale, () -> new StyleSpan(Typeface.BOLD), messageResult.bodySnippet, highlightSubstring));
dateView.setText(DateUtils.getBriefRelativeTimeSpanString(getContext(), locale, messageResult.receivedTimestampMs));
archivedView.setVisibility(GONE);
unreadIndicator.setVisibility(GONE);
@ -333,44 +330,6 @@ public class ConversationListItem extends RelativeLayout
unreadIndicator.setVisibility(View.VISIBLE);
}
private Spanned getHighlightedSpan(@NonNull Locale locale,
@Nullable String value,
@Nullable String highlight)
{
if (TextUtils.isEmpty(value)) {
return new SpannableString("");
}
value = value.replaceAll("\n", " ");
if (TextUtils.isEmpty(highlight)) {
return new SpannableString(value);
}
String normalizedValue = value.toLowerCase(locale);
String normalizedTest = highlight.toLowerCase(locale);
List<String> testTokens = Stream.of(normalizedTest.split(" ")).filter(s -> s.trim().length() > 0).toList();
Spannable spanned = new SpannableString(value);
int searchStartIndex = 0;
for (String token : testTokens) {
if (searchStartIndex >= spanned.length()) {
break;
}
int start = normalizedValue.indexOf(token, searchStartIndex);
if (start >= 0) {
int end = Math.min(start + token.length(), spanned.length());
spanned.setSpan(new StyleSpan(Typeface.BOLD), start, end, Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
searchStartIndex = end;
}
}
return spanned;
}
@Override
public void onModified(final Recipient recipient) {
Util.runOnMain(() -> {

View File

@ -96,6 +96,7 @@ public class DatabaseUpgradeActivity extends BaseActivity {
public static final int COLOR_MIGRATION = 412;
public static final int UNIDENTIFIED_DELIVERY = 422;
public static final int SIGNALING_KEY_DEPRECATION = 447;
public static final int CONVERSATION_SEARCH = 455;
private static final SortedSet<Integer> UPGRADE_VERSIONS = new TreeSet<Integer>() {{
add(NO_MORE_KEY_EXCHANGE_PREFIX_VERSION);
@ -123,6 +124,7 @@ public class DatabaseUpgradeActivity extends BaseActivity {
add(COLOR_MIGRATION);
add(UNIDENTIFIED_DELIVERY);
add(SIGNALING_KEY_DEPRECATION);
add(CONVERSATION_SEARCH);
}};
private MasterSecret masterSecret;

View File

@ -271,7 +271,7 @@ public class MessageDetailsActivity extends PassphraseRequiredActionBarActivity
toFromRes = R.string.message_details_header__from;
}
toFrom.setText(toFromRes);
conversationItem.bind(messageRecord, Optional.absent(), Optional.absent(), glideRequests, dynamicLanguage.getCurrentLocale(), new HashSet<>(), recipient, false);
conversationItem.bind(messageRecord, Optional.absent(), Optional.absent(), glideRequests, dynamicLanguage.getCurrentLocale(), new HashSet<>(), recipient, null, false);
recipientsList.setAdapter(new MessageDetailsRecipientAdapter(this, glideRequests, messageRecord, recipients, isPushGroup));
}

View File

@ -0,0 +1,88 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.constraint.ConstraintLayout;
import android.util.AttributeSet;
import android.view.View;
import android.widget.TextView;
import org.thoughtcrime.securesms.R;
/**
* Bottom navigation bar shown in the {@link org.thoughtcrime.securesms.conversation.ConversationActivity}
* when the user is searching within a conversation. Shows details about the results and allows the
* user to move between them.
*/
public class ConversationSearchBottomBar extends ConstraintLayout {
private View searchDown;
private View searchUp;
private TextView searchPositionText;
private View progressWheel;
private EventListener eventListener;
public ConversationSearchBottomBar(Context context) {
super(context);
}
public ConversationSearchBottomBar(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
this.searchUp = findViewById(R.id.conversation_search_up);
this.searchDown = findViewById(R.id.conversation_search_down);
this.searchPositionText = findViewById(R.id.conversation_search_position);
this.progressWheel = findViewById(R.id.conversation_search_progress_wheel);
}
public void setData(int position, int count) {
progressWheel.setVisibility(GONE);
searchUp.setOnClickListener(v -> {
if (eventListener != null) {
eventListener.onSearchMoveUpPressed();
}
});
searchDown.setOnClickListener(v -> {
if (eventListener != null) {
eventListener.onSearchMoveDownPressed();
}
});
if (count > 0) {
searchPositionText.setText(getResources().getString(R.string.ConversationActivity_search_position, position + 1, count));
} else {
searchPositionText.setText(R.string.ConversationActivity_no_results);
}
setViewEnabled(searchUp, position < (count - 1));
setViewEnabled(searchDown, position > 0);
}
public void showLoading() {
progressWheel.setVisibility(VISIBLE);
}
private void setViewEnabled(@NonNull View view, boolean enabled) {
view.setEnabled(enabled);
view.setAlpha(enabled ? 1f : 0.25f);
}
public void setEventListener(@Nullable EventListener eventListener) {
this.eventListener = eventListener;
}
public interface EventListener {
void onSearchMoveUpPressed();
void onSearchMoveDownPressed();
}
}

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;

View File

@ -30,6 +30,8 @@ public class CursorList<T> implements List<T>, Closeable {
public CursorList(@NonNull Cursor cursor, @NonNull ModelBuilder<T> modelBuilder) {
this.cursor = cursor;
this.modelBuilder = modelBuilder;
forceQueryLoad();
}
public static <T> CursorList<T> emptyList() {
@ -195,6 +197,10 @@ public class CursorList<T> implements List<T>, Closeable {
cursor.unregisterContentObserver(observer);
}
private void forceQueryLoad() {
cursor.getCount();
}
public interface ModelBuilder<T> {
T build(@NonNull Cursor cursor);
}

View File

@ -124,6 +124,10 @@ public abstract class CursorRecyclerViewAdapter<VH extends RecyclerView.ViewHold
+ (hasFooterView() ? 1 : 0);
}
public int getCursorCount() {
return cursor.getCount();
}
@SuppressWarnings("unchecked")
@Override
public final void onViewRecycled(ViewHolder holder) {
@ -190,7 +194,7 @@ public abstract class CursorRecyclerViewAdapter<VH extends RecyclerView.ViewHold
throw new IllegalStateException("this should only be called when the cursor is valid");
}
if (!cursor.moveToPosition(getCursorPosition(position))) {
throw new IllegalStateException("couldn't move cursor to position " + position);
throw new IllegalStateException("couldn't move cursor to position " + position + " (actual cursor position " + getCursorPosition(position) + ")");
}
return cursor;
}

View File

@ -183,6 +183,26 @@ public class MmsSmsDatabase extends Database {
return -1;
}
public int getMessagePositionInConversation(long threadId, long receivedTimestamp, @NonNull Address address) {
String order = MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " DESC";
String selection = MmsSmsColumns.THREAD_ID + " = " + threadId;
try (Cursor cursor = queryTables(new String[]{ MmsSmsColumns.NORMALIZED_DATE_RECEIVED, MmsSmsColumns.ADDRESS }, selection, order, null)) {
String serializedAddress = address.serialize();
boolean isOwnNumber = Util.isOwnNumber(context, address);
while (cursor != null && cursor.moveToNext()) {
boolean timestampMatches = cursor.getLong(0) == receivedTimestamp;
boolean addressMatches = serializedAddress.equals(cursor.getString(1));
if (timestampMatches && (addressMatches || isOwnNumber)) {
return cursor.getPosition();
}
}
}
return -1;
}
/**
* Retrieves the position of the message with the provided timestamp in the query results you'd
* get from calling {@link #getConversation(long)}.

View File

@ -21,78 +21,122 @@ public class SearchDatabase extends Database {
public static final String SMS_FTS_TABLE_NAME = "sms_fts";
public static final String MMS_FTS_TABLE_NAME = "mms_fts";
public static final String ID = "rowid";
public static final String BODY = MmsSmsColumns.BODY;
public static final String RANK = "rank";
public static final String SNIPPET = "snippet";
public static final String ID = "rowid";
public static final String BODY = MmsSmsColumns.BODY;
public static final String THREAD_ID = MmsSmsColumns.THREAD_ID;
public static final String SNIPPET = "snippet";
public static final String CONVERSATION_ADDRESS = "conversation_address";
public static final String MESSAGE_ADDRESS = "message_address";
public static final String[] CREATE_TABLE = {
"CREATE VIRTUAL TABLE " + SMS_FTS_TABLE_NAME + " USING fts5(" + BODY + ", content=" + SmsDatabase.TABLE_NAME + ", content_rowid=" + SmsDatabase.ID + ");",
"CREATE VIRTUAL TABLE " + SMS_FTS_TABLE_NAME + " USING fts5(" + BODY + ", " + THREAD_ID + " UNINDEXED, content=" + SmsDatabase.TABLE_NAME + ", content_rowid=" + SmsDatabase.ID + ");",
"CREATE TRIGGER sms_ai AFTER INSERT ON " + SmsDatabase.TABLE_NAME + " BEGIN\n" +
" INSERT INTO " + SMS_FTS_TABLE_NAME + "(" + ID + ", " + BODY + ") VALUES (new." + SmsDatabase.ID + ", new." + SmsDatabase.BODY + ");\n" +
" INSERT INTO " + SMS_FTS_TABLE_NAME + "(" + ID + ", " + BODY + ", " + THREAD_ID + ") VALUES (new." + SmsDatabase.ID + ", new." + SmsDatabase.BODY + ", new." + SmsDatabase.THREAD_ID + ");\n" +
"END;\n",
"CREATE TRIGGER sms_ad AFTER DELETE ON " + SmsDatabase.TABLE_NAME + " BEGIN\n" +
" INSERT INTO " + SMS_FTS_TABLE_NAME + "(" + SMS_FTS_TABLE_NAME + ", " + ID + ", " + BODY + ") VALUES('delete', old." + SmsDatabase.ID + ", old." + SmsDatabase.BODY + ");\n" +
" INSERT INTO " + SMS_FTS_TABLE_NAME + "(" + SMS_FTS_TABLE_NAME + ", " + ID + ", " + BODY + ", " + THREAD_ID + ") VALUES('delete', old." + SmsDatabase.ID + ", old." + SmsDatabase.BODY + ", old." + SmsDatabase.THREAD_ID + ");\n" +
"END;\n",
"CREATE TRIGGER sms_au AFTER UPDATE ON " + SmsDatabase.TABLE_NAME + " BEGIN\n" +
" INSERT INTO " + SMS_FTS_TABLE_NAME + "(" + SMS_FTS_TABLE_NAME + ", " + ID + ", " + BODY + ") VALUES('delete', old." + SmsDatabase.ID + ", old." + SmsDatabase.BODY + ");\n" +
" INSERT INTO " + SMS_FTS_TABLE_NAME + "(" + ID + ", " + BODY + ") VALUES(new." + SmsDatabase.ID + ", new." + SmsDatabase.BODY + ");\n" +
" INSERT INTO " + SMS_FTS_TABLE_NAME + "(" + SMS_FTS_TABLE_NAME + ", " + ID + ", " + BODY + ", " + THREAD_ID + ") VALUES('delete', old." + SmsDatabase.ID + ", old." + SmsDatabase.BODY + ", old." + SmsDatabase.THREAD_ID + ");\n" +
" INSERT INTO " + SMS_FTS_TABLE_NAME + "(" + ID + ", " + BODY + ", " + THREAD_ID + ") VALUES(new." + SmsDatabase.ID + ", new." + SmsDatabase.BODY + ", new." + SmsDatabase.THREAD_ID + ");\n" +
"END;",
"CREATE VIRTUAL TABLE " + MMS_FTS_TABLE_NAME + " USING fts5(" + BODY + ", content=" + MmsDatabase.TABLE_NAME + ", content_rowid=" + MmsDatabase.ID + ");",
"CREATE VIRTUAL TABLE " + MMS_FTS_TABLE_NAME + " USING fts5(" + BODY + ", " + THREAD_ID + " UNINDEXED, content=" + MmsDatabase.TABLE_NAME + ", content_rowid=" + MmsDatabase.ID + ");",
"CREATE TRIGGER mms_ai AFTER INSERT ON " + MmsDatabase.TABLE_NAME + " BEGIN\n" +
" INSERT INTO " + MMS_FTS_TABLE_NAME + "(" + ID + ", " + BODY + ") VALUES (new." + MmsDatabase.ID + ", new." + MmsDatabase.BODY + ");\n" +
" INSERT INTO " + MMS_FTS_TABLE_NAME + "(" + ID + ", " + BODY + ", " + THREAD_ID + ") VALUES (new." + MmsDatabase.ID + ", new." + MmsDatabase.BODY + ", new." + MmsDatabase.THREAD_ID + ");\n" +
"END;\n",
"CREATE TRIGGER mms_ad AFTER DELETE ON " + MmsDatabase.TABLE_NAME + " BEGIN\n" +
" INSERT INTO " + MMS_FTS_TABLE_NAME + "(" + MMS_FTS_TABLE_NAME + ", " + ID + ", " + BODY + ") VALUES('delete', old." + MmsDatabase.ID + ", old." + MmsDatabase.BODY + ");\n" +
" INSERT INTO " + MMS_FTS_TABLE_NAME + "(" + MMS_FTS_TABLE_NAME + ", " + ID + ", " + BODY + ", " + THREAD_ID + ") VALUES('delete', old." + MmsDatabase.ID + ", old." + MmsDatabase.BODY + ", old." + MmsDatabase.THREAD_ID + ");\n" +
"END;\n",
"CREATE TRIGGER mms_au AFTER UPDATE ON " + MmsDatabase.TABLE_NAME + " BEGIN\n" +
" INSERT INTO " + MMS_FTS_TABLE_NAME + "(" + MMS_FTS_TABLE_NAME + ", " + ID + ", " + BODY + ") VALUES('delete', old." + MmsDatabase.ID + ", old." + MmsDatabase.BODY + ");\n" +
" INSERT INTO " + MMS_FTS_TABLE_NAME + "(" + ID + ", " + BODY + ") VALUES (new." + MmsDatabase.ID + ", new." + MmsDatabase.BODY + ");\n" +
" INSERT INTO " + MMS_FTS_TABLE_NAME + "(" + MMS_FTS_TABLE_NAME + ", " + ID + ", " + BODY + ", " + THREAD_ID + ") VALUES('delete', old." + MmsDatabase.ID + ", old." + MmsDatabase.BODY + ", old." + MmsDatabase.THREAD_ID + ");\n" +
" INSERT INTO " + MMS_FTS_TABLE_NAME + "(" + ID + ", " + BODY + ", " + THREAD_ID + ") VALUES (new." + MmsDatabase.ID + ", new." + MmsDatabase.BODY + ", new." + MmsDatabase.THREAD_ID + ");\n" +
"END;"
};
private static final String MESSAGES_QUERY =
"SELECT " +
ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ADDRESS + ", " +
ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ADDRESS + " AS " + CONVERSATION_ADDRESS + ", " +
MmsSmsColumns.ADDRESS + " AS " + MESSAGE_ADDRESS + ", " +
"snippet(" + SMS_FTS_TABLE_NAME + ", -1, '', '', '...', 7) AS " + SNIPPET + ", " +
SmsDatabase.TABLE_NAME + "." + SmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + ", " +
MmsSmsColumns.THREAD_ID + " " +
SMS_FTS_TABLE_NAME + "." + THREAD_ID + " " +
"FROM " + SmsDatabase.TABLE_NAME + " " +
"INNER JOIN " + SMS_FTS_TABLE_NAME + " ON " + SMS_FTS_TABLE_NAME + "." + ID + " = " + SmsDatabase.TABLE_NAME + "." + SmsDatabase.ID + " " +
"INNER JOIN " + ThreadDatabase.TABLE_NAME + " ON " + SmsDatabase.TABLE_NAME + "." + MmsSmsColumns.THREAD_ID + " = " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ID + " " +
"INNER JOIN " + ThreadDatabase.TABLE_NAME + " ON " + SMS_FTS_TABLE_NAME + "." + THREAD_ID + " = " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ID + " " +
"WHERE " + SMS_FTS_TABLE_NAME + " MATCH ? " +
"UNION ALL " +
"SELECT " +
ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ADDRESS + ", " +
ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ADDRESS + " AS " + CONVERSATION_ADDRESS + ", " +
MmsSmsColumns.ADDRESS + " AS " + MESSAGE_ADDRESS + ", " +
"snippet(" + MMS_FTS_TABLE_NAME + ", -1, '', '', '...', 7) AS " + SNIPPET + ", " +
MmsDatabase.TABLE_NAME + "." + MmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + ", " +
MmsSmsColumns.THREAD_ID + " " +
MMS_FTS_TABLE_NAME + "." + THREAD_ID + " " +
"FROM " + MmsDatabase.TABLE_NAME + " " +
"INNER JOIN " + MMS_FTS_TABLE_NAME + " ON " + MMS_FTS_TABLE_NAME + "." + ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " " +
"INNER JOIN " + ThreadDatabase.TABLE_NAME + " ON " + MmsDatabase.TABLE_NAME + "." + MmsSmsColumns.THREAD_ID + " = " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ID + " " +
"INNER JOIN " + ThreadDatabase.TABLE_NAME + " ON " + MMS_FTS_TABLE_NAME + "." + THREAD_ID + " = " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ID + " " +
"WHERE " + MMS_FTS_TABLE_NAME + " MATCH ? " +
"ORDER BY " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " DESC " +
"LIMIT 500";
private static final String MESSAGES_FOR_THREAD_QUERY =
"SELECT " +
ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ADDRESS + " AS " + CONVERSATION_ADDRESS + ", " +
MmsSmsColumns.ADDRESS + " AS " + MESSAGE_ADDRESS + ", " +
"snippet(" + SMS_FTS_TABLE_NAME + ", -1, '', '', '...', 7) AS " + SNIPPET + ", " +
SmsDatabase.TABLE_NAME + "." + SmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + ", " +
SMS_FTS_TABLE_NAME + "." + THREAD_ID + " " +
"FROM " + SmsDatabase.TABLE_NAME + " " +
"INNER JOIN " + SMS_FTS_TABLE_NAME + " ON " + SMS_FTS_TABLE_NAME + "." + ID + " = " + SmsDatabase.TABLE_NAME + "." + SmsDatabase.ID + " " +
"INNER JOIN " + ThreadDatabase.TABLE_NAME + " ON " + SMS_FTS_TABLE_NAME + "." + THREAD_ID + " = " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ID + " " +
"WHERE " + SMS_FTS_TABLE_NAME + " MATCH ? AND " + SmsDatabase.TABLE_NAME + "." + MmsSmsColumns.THREAD_ID + " = ? " +
"UNION ALL " +
"SELECT " +
ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ADDRESS + " AS " + CONVERSATION_ADDRESS + ", " +
MmsSmsColumns.ADDRESS + " AS " + MESSAGE_ADDRESS + ", " +
"snippet(" + MMS_FTS_TABLE_NAME + ", -1, '', '', '...', 7) AS " + SNIPPET + ", " +
MmsDatabase.TABLE_NAME + "." + MmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + ", " +
MMS_FTS_TABLE_NAME + "." + THREAD_ID + " " +
"FROM " + MmsDatabase.TABLE_NAME + " " +
"INNER JOIN " + MMS_FTS_TABLE_NAME + " ON " + MMS_FTS_TABLE_NAME + "." + ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " " +
"INNER JOIN " + ThreadDatabase.TABLE_NAME + " ON " + MMS_FTS_TABLE_NAME + "." + THREAD_ID + " = " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ID + " " +
"WHERE " + MMS_FTS_TABLE_NAME + " MATCH ? AND " + MmsDatabase.TABLE_NAME + "." + MmsSmsColumns.THREAD_ID + " = ? " +
"ORDER BY " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " DESC " +
"LIMIT 500";
public SearchDatabase(@NonNull Context context, @NonNull SQLCipherOpenHelper databaseHelper) {
super(context, databaseHelper);
}
public Cursor queryMessages(@NonNull String query) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
List<String> tokens = Stream.of(query.split(" ")).filter(s -> s.trim().length() > 0).toList();
String prefixQuery = Util.join(tokens, "* ");
prefixQuery += "*";
SQLiteDatabase db = databaseHelper.getReadableDatabase();
String prefixQuery = adjustQuery(query);
Cursor cursor = db.rawQuery(MESSAGES_QUERY, new String[] { prefixQuery, prefixQuery });
setNotifyConverationListListeners(cursor);
return cursor;
}
public Cursor queryMessages(@NonNull String query, long threadId) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
String prefixQuery = adjustQuery(query);
Cursor cursor = db.rawQuery(MESSAGES_FOR_THREAD_QUERY, new String[] { prefixQuery, String.valueOf(threadId), prefixQuery, String.valueOf(threadId) });
setNotifyConverationListListeners(cursor);
return cursor;
}
private String adjustQuery(@NonNull String query) {
List<String> tokens = Stream.of(query.split(" ")).filter(s -> s.trim().length() > 0).toList();
String prefixQuery = Util.join(tokens, "* ");
prefixQuery += "*";
return prefixQuery;
}
}

View File

@ -60,8 +60,9 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
private static final int ATTACHMENT_CAPTIONS = 14;
private static final int ATTACHMENT_CAPTIONS_FIX = 15;
private static final int PREVIEWS = 16;
private static final int CONVERSATION_SEARCH = 17;
private static final int DATABASE_VERSION = 16;
private static final int DATABASE_VERSION = 17;
private static final String DATABASE_NAME = "signal.db";
private final Context context;
@ -201,9 +202,29 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
}
if (oldVersion < FULL_TEXT_SEARCH) {
for (String sql : SearchDatabase.CREATE_TABLE) {
db.execSQL(sql);
}
db.execSQL("CREATE VIRTUAL TABLE sms_fts USING fts5(body, content=sms, content_rowid=_id)");
db.execSQL("CREATE TRIGGER sms_ai AFTER INSERT ON sms BEGIN\n" +
" INSERT INTO sms_fts(rowid, body) VALUES (new._id, new.body);\n" +
"END;");
db.execSQL("CREATE TRIGGER sms_ad AFTER DELETE ON sms BEGIN\n" +
" INSERT INTO sms_fts(sms_fts, rowid, body) VALUES('delete', old._id, old.body);\n" +
"END;\n");
db.execSQL("CREATE TRIGGER sms_au AFTER UPDATE ON sms BEGIN\n" +
" INSERT INTO sms_fts(sms_fts, rowid, body) VALUES('delete', old._id, old.body);\n" +
" INSERT INTO sms_fts(rowid, body) VALUES(new._id, new.body);\n" +
"END;");
db.execSQL("CREATE VIRTUAL TABLE mms_fts USING fts5(body, content=mms, content_rowid=_id)");
db.execSQL("CREATE TRIGGER mms_ai AFTER INSERT ON mms BEGIN\n" +
" INSERT INTO mms_fts(rowid, body) VALUES (new._id, new.body);\n" +
"END;");
db.execSQL("CREATE TRIGGER mms_ad AFTER DELETE ON mms BEGIN\n" +
" INSERT INTO mms_fts(mms_fts, rowid, body) VALUES('delete', old._id, old.body);\n" +
"END;\n");
db.execSQL("CREATE TRIGGER mms_au AFTER UPDATE ON mms BEGIN\n" +
" INSERT INTO mms_fts(mms_fts, rowid, body) VALUES('delete', old._id, old.body);\n" +
" INSERT INTO mms_fts(rowid, body) VALUES(new._id, new.body);\n" +
"END;");
Log.i(TAG, "Beginning to build search index.");
long start = SystemClock.elapsedRealtime();
@ -313,6 +334,55 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
db.execSQL("ALTER TABLE mms ADD COLUMN previews TEXT");
}
if (oldVersion < CONVERSATION_SEARCH) {
db.execSQL("DROP TABLE sms_fts");
db.execSQL("DROP TABLE mms_fts");
db.execSQL("DROP TRIGGER sms_ai");
db.execSQL("DROP TRIGGER sms_au");
db.execSQL("DROP TRIGGER sms_ad");
db.execSQL("DROP TRIGGER mms_ai");
db.execSQL("DROP TRIGGER mms_au");
db.execSQL("DROP TRIGGER mms_ad");
db.execSQL("CREATE VIRTUAL TABLE sms_fts USING fts5(body, thread_id UNINDEXED, content=sms, content_rowid=_id)");
db.execSQL("CREATE TRIGGER sms_ai AFTER INSERT ON sms BEGIN\n" +
" INSERT INTO sms_fts(rowid, body, thread_id) VALUES (new._id, new.body, new.thread_id);\n" +
"END;");
db.execSQL("CREATE TRIGGER sms_ad AFTER DELETE ON sms BEGIN\n" +
" INSERT INTO sms_fts(sms_fts, rowid, body, thread_id) VALUES('delete', old._id, old.body, old.thread_id);\n" +
"END;\n");
db.execSQL("CREATE TRIGGER sms_au AFTER UPDATE ON sms BEGIN\n" +
" INSERT INTO sms_fts(sms_fts, rowid, body, thread_id) VALUES('delete', old._id, old.body, old.thread_id);\n" +
" INSERT INTO sms_fts(rowid, body, thread_id) VALUES(new._id, new.body, new.thread_id);\n" +
"END;");
db.execSQL("CREATE VIRTUAL TABLE mms_fts USING fts5(body, thread_id UNINDEXED, content=mms, content_rowid=_id)");
db.execSQL("CREATE TRIGGER mms_ai AFTER INSERT ON mms BEGIN\n" +
" INSERT INTO mms_fts(rowid, body, thread_id) VALUES (new._id, new.body, new.thread_id);\n" +
"END;");
db.execSQL("CREATE TRIGGER mms_ad AFTER DELETE ON mms BEGIN\n" +
" INSERT INTO mms_fts(mms_fts, rowid, body, thread_id) VALUES('delete', old._id, old.body, old.thread_id);\n" +
"END;\n");
db.execSQL("CREATE TRIGGER mms_au AFTER UPDATE ON mms BEGIN\n" +
" INSERT INTO mms_fts(mms_fts, rowid, body, thread_id) VALUES('delete', old._id, old.body, old.thread_id);\n" +
" INSERT INTO mms_fts(rowid, body, thread_id) VALUES(new._id, new.body, new.thread_id);\n" +
"END;");
Log.i(TAG, "Beginning to build search index.");
long start = SystemClock.elapsedRealtime();
db.execSQL("INSERT INTO sms_fts (rowid, body, thread_id) SELECT _id, body, thread_id FROM sms");
long smsFinished = SystemClock.elapsedRealtime();
Log.i(TAG, "Indexing SMS completed in " + (smsFinished - start) + " ms");
db.execSQL("INSERT INTO mms_fts (rowid, body, thread_id) SELECT _id, body, thread_id FROM mms");
long mmsFinished = SystemClock.elapsedRealtime();
Log.i(TAG, "Indexing MMS completed in " + (mmsFinished - smsFinished) + " ms");
Log.i(TAG, "Indexing finished. Total time: " + (mmsFinished - start) + " ms");
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();

View File

@ -172,7 +172,7 @@ public class SearchFragment extends Fragment implements SearchListAdapter.EventL
ConversationListActivity conversationList = (ConversationListActivity) getActivity();
if (conversationList != null) {
conversationList.openConversation(message.threadId,
message.recipient,
message.conversationRecipient,
ThreadDatabase.DistributionTypes.DEFAULT,
-1,
startingPosition);

View File

@ -19,11 +19,14 @@ import org.thoughtcrime.securesms.database.MmsSmsColumns;
import org.thoughtcrime.securesms.database.SearchDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.database.model.ThreadRecord;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.search.model.MessageResult;
import org.thoughtcrime.securesms.search.model.SearchResult;
import org.thoughtcrime.securesms.util.Stopwatch;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@ -32,7 +35,9 @@ import java.util.concurrent.Executor;
/**
* Manages data retrieval for search.
*/
class SearchRepository {
public class SearchRepository {
private static final String TAG = SearchRepository.class.getSimpleName();
private static final Set<Character> BANNED_CHARACTERS = new HashSet<>();
static {
@ -58,12 +63,12 @@ class SearchRepository {
private final ContactAccessor contactAccessor;
private final Executor executor;
SearchRepository(@NonNull Context context,
@NonNull SearchDatabase searchDatabase,
@NonNull ContactsDatabase contactsDatabase,
@NonNull ThreadDatabase threadDatabase,
@NonNull ContactAccessor contactAccessor,
@NonNull Executor executor)
public SearchRepository(@NonNull Context context,
@NonNull SearchDatabase searchDatabase,
@NonNull ContactsDatabase contactsDatabase,
@NonNull ThreadDatabase threadDatabase,
@NonNull ContactAccessor contactAccessor,
@NonNull Executor executor)
{
this.context = context.getApplicationContext();
this.searchDatabase = searchDatabase;
@ -73,22 +78,48 @@ class SearchRepository {
this.executor = executor;
}
void query(@NonNull String query, @NonNull Callback callback) {
public void query(@NonNull String query, @NonNull Callback<SearchResult> callback) {
if (TextUtils.isEmpty(query)) {
callback.onResult(SearchResult.EMPTY);
return;
}
executor.execute(() -> {
String cleanQuery = sanitizeQuery(query);
CursorList<Recipient> contacts = queryContacts(cleanQuery);
CursorList<ThreadRecord> conversations = queryConversations(cleanQuery);
CursorList<MessageResult> messages = queryMessages(cleanQuery);
Stopwatch timer = new Stopwatch("FtsQuery");
String cleanQuery = sanitizeQuery(query);
timer.split("clean");
CursorList<Recipient> contacts = queryContacts(cleanQuery);
timer.split("contacts");
CursorList<ThreadRecord> conversations = queryConversations(cleanQuery);
timer.split("conversations");
CursorList<MessageResult> messages = queryMessages(cleanQuery);
timer.split("messages");
timer.stop(TAG);
callback.onResult(new SearchResult(cleanQuery, contacts, conversations, messages));
});
}
public void query(@NonNull String query, long threadId, @NonNull Callback<CursorList<MessageResult>> callback) {
if (TextUtils.isEmpty(query)) {
callback.onResult(CursorList.emptyList());
return;
}
executor.execute(() -> {
long startTime = System.currentTimeMillis();
CursorList<MessageResult> messages = queryMessages(sanitizeQuery(query), threadId);
Log.d(TAG, "[ConversationQuery] " + (System.currentTimeMillis() - startTime) + " ms");
callback.onResult(messages);
});
}
private CursorList<Recipient> queryContacts(String query) {
if (!Permissions.hasAny(context, Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)) {
return CursorList.emptyList();
@ -116,6 +147,12 @@ class SearchRepository {
: CursorList.emptyList();
}
private CursorList<MessageResult> queryMessages(@NonNull String query, long threadId) {
Cursor messages = searchDatabase.queryMessages(query, threadId);
return messages != null ? new CursorList<>(messages, new MessageModelBuilder(context))
: CursorList.emptyList();
}
/**
* Unfortunately {@link DatabaseUtils#sqlEscapeString(String)} is not sufficient for our purposes.
* MATCH queries have a separate format of their own that disallow most "special" characters.
@ -177,17 +214,19 @@ class SearchRepository {
@Override
public MessageResult build(@NonNull Cursor cursor) {
Address address = Address.fromSerialized(cursor.getString(0));
Recipient recipient = Recipient.from(context, address, false);
String body = cursor.getString(cursor.getColumnIndexOrThrow(SearchDatabase.SNIPPET));
long receivedMs = cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.NORMALIZED_DATE_RECEIVED));
long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.THREAD_ID));
Address conversationAddress = Address.fromSerialized(cursor.getString(cursor.getColumnIndex(SearchDatabase.CONVERSATION_ADDRESS)));
Address messageAddress = Address.fromSerialized(cursor.getString(cursor.getColumnIndexOrThrow(SearchDatabase.MESSAGE_ADDRESS)));
Recipient conversationRecipient = Recipient.from(context, conversationAddress, false);
Recipient messageRecipient = Recipient.from(context, messageAddress, false);
String body = cursor.getString(cursor.getColumnIndexOrThrow(SearchDatabase.SNIPPET));
long receivedMs = cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.NORMALIZED_DATE_RECEIVED));
long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.THREAD_ID));
return new MessageResult(recipient, body, threadId, receivedMs);
return new MessageResult(conversationRecipient, messageRecipient, body, threadId, receivedMs);
}
}
public interface Callback {
void onResult(@NonNull SearchResult result);
public interface Callback<E> {
void onResult(@NonNull E result);
}
}

View File

@ -11,6 +11,7 @@ import android.text.TextUtils;
import org.thoughtcrime.securesms.search.model.SearchResult;
import org.thoughtcrime.securesms.util.Debouncer;
import org.thoughtcrime.securesms.util.Util;
/**
* A {@link ViewModel} for handling all the business logic and interactions that take place inside
@ -28,7 +29,7 @@ class SearchViewModel extends ViewModel {
private String lastQuery;
SearchViewModel(@NonNull SearchRepository searchRepository) {
private SearchViewModel(@NonNull SearchRepository searchRepository) {
this.searchResult = new ObservingLiveData();
this.searchRepository = searchRepository;
this.debouncer = new Debouncer(500);
@ -49,7 +50,15 @@ class SearchViewModel extends ViewModel {
void updateQuery(String query) {
lastQuery = query;
debouncer.publish(() -> searchRepository.query(query, searchResult::postValue));
debouncer.publish(() -> searchRepository.query(query, result -> {
Util.runOnMain(() -> {
if (query.equals(lastQuery)) {
searchResult.setValue(result);
} else {
result.close();
}
});
}));
}
@NonNull

View File

@ -9,19 +9,22 @@ import org.thoughtcrime.securesms.recipients.Recipient;
*/
public class MessageResult {
public final Recipient recipient;
public final Recipient conversationRecipient;
public final Recipient messageRecipient;
public final String bodySnippet;
public final long threadId;
public final long receivedTimestampMs;
public MessageResult(@NonNull Recipient recipient,
public MessageResult(@NonNull Recipient conversationRecipient,
@NonNull Recipient messageRecipient,
@NonNull String bodySnippet,
long threadId,
long receivedTimestampMs)
{
this.recipient = recipient;
this.bodySnippet = bodySnippet;
this.threadId = threadId;
this.receivedTimestampMs = receivedTimestampMs;
this.conversationRecipient = conversationRecipient;
this.messageRecipient = messageRecipient;
this.bodySnippet = bodySnippet;
this.threadId = threadId;
this.receivedTimestampMs = receivedTimestampMs;
}
}

View File

@ -0,0 +1,35 @@
package org.thoughtcrime.securesms.util;
import android.arch.lifecycle.MutableLiveData;
import java.io.Closeable;
/**
* Implementation of {@link android.arch.lifecycle.LiveData} that will handle closing the contained
* {@link Closeable} when the value changes.
*/
public class CloseableLiveData<E extends Closeable> extends MutableLiveData<E> {
@Override
public void setValue(E value) {
setValue(value, true);
}
public void setValue(E value, boolean closePrevious) {
E previous = getValue();
if (previous != null && closePrevious) {
Util.close(previous);
}
super.setValue(value);
}
public void close() {
E value = getValue();
if (value != null) {
Util.close(value);
}
}
}

View File

@ -0,0 +1,96 @@
package org.thoughtcrime.securesms.util;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.style.CharacterStyle;
import com.annimon.stream.Stream;
import org.whispersystems.libsignal.util.Pair;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
public class SearchUtil {
public static Spannable getHighlightedSpan(@NonNull Locale locale,
@NonNull StyleFactory styleFactory,
@Nullable String text,
@Nullable String highlight)
{
if (TextUtils.isEmpty(text)) {
return new SpannableString("");
}
text = text.replaceAll("\n", " ");
return getHighlightedSpan(locale, styleFactory, new SpannableString(text), highlight);
}
public static Spannable getHighlightedSpan(@NonNull Locale locale,
@NonNull StyleFactory styleFactory,
@Nullable Spannable text,
@Nullable String highlight)
{
if (TextUtils.isEmpty(text)) {
return new SpannableString("");
}
if (TextUtils.isEmpty(highlight)) {
return text;
}
List<Pair<Integer, Integer>> ranges = getHighlightRanges(locale, text.toString(), highlight);
SpannableString spanned = new SpannableString(text);
for (Pair<Integer, Integer> range : ranges) {
spanned.setSpan(styleFactory.create(), range.first(), range.second(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
}
return spanned;
}
static List<Pair<Integer, Integer>> getHighlightRanges(@NonNull Locale locale,
@NonNull String text,
@NonNull String highlight)
{
String normalizedText = text.toLowerCase(locale);
String normalizedHighlight = highlight.toLowerCase(locale);
List<String> highlightTokens = Stream.of(normalizedHighlight.split("\\s")).filter(s -> s.trim().length() > 0).toList();
List<String> textTokens = Stream.of(normalizedText.split("\\s")).filter(s -> s.trim().length() > 0).toList();
List<Pair<Integer, Integer>> ranges = new LinkedList<>();
int textListIndex = 0;
int textCharIndex = 0;
for (String highlightToken : highlightTokens) {
for (int i = textListIndex; i < textTokens.size(); i++) {
if (textTokens.get(i).startsWith(highlightToken)) {
textListIndex = i + 1;
ranges.add(new Pair<>(textCharIndex, textCharIndex + highlightToken.length()));
textCharIndex += textTokens.get(i).length() + 1;
break;
}
textCharIndex += textTokens.get(i).length() + 1;
}
}
if (ranges.size() != highlightTokens.size()) {
return Collections.emptyList();
}
return ranges;
}
public interface StyleFactory {
CharacterStyle create();
}
}

View File

@ -0,0 +1,65 @@
package org.thoughtcrime.securesms.util;
import org.junit.Test;
import org.whispersystems.libsignal.util.Pair;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public class SearchUtilTest {
private static final Locale LOCALE = Locale.ENGLISH;
@Test
public void getHighlightRanges_singleHighlightToken() {
String text = "abc";
String highlight = "a";
List<Pair<Integer, Integer>> result = SearchUtil.getHighlightRanges(LOCALE, text, highlight);
assertEquals(Arrays.asList(new Pair<>(0, 1)), result);
}
@Test
public void getHighlightRanges_multipleHighlightTokens() {
String text = "a bc";
String highlight = "a b";
List<Pair<Integer, Integer>> result = SearchUtil.getHighlightRanges(LOCALE, text, highlight);
assertEquals(Arrays.asList(new Pair<>(0, 1), new Pair<>(2, 3)), result);
text = "abc def";
highlight = "ab de";
result = SearchUtil.getHighlightRanges(LOCALE, text, highlight);
assertEquals(Arrays.asList(new Pair<>(0, 2), new Pair<>(4, 6)), result);
}
@Test
public void getHighlightRanges_onlyHighlightPrefixes() {
String text = "abc";
String highlight = "b";
List<Pair<Integer, Integer>> result = SearchUtil.getHighlightRanges(LOCALE, text, highlight);
assertTrue(result.isEmpty());
text = "abc";
highlight = "c";
result = SearchUtil.getHighlightRanges(LOCALE, text, highlight);
assertTrue(result.isEmpty());
}
@Test
public void getHighlightRanges_resultNotInFirstToken() {
String text = "abc def ghi";
String highlight = "gh";
List<Pair<Integer, Integer>> result = SearchUtil.getHighlightRanges(LOCALE, text, highlight);
assertEquals(Arrays.asList(new Pair<>(8, 10)), result);
}
}