Implemented full-text search.

You can now use the search bar on the conversation list to find
conversations, messages, and contacts.
This commit is contained in:
Greyson Parrelli 2018-04-06 18:15:24 -07:00
parent c0b75c2ef5
commit 0449647cf9
28 changed files with 1505 additions and 70 deletions

View File

@ -39,6 +39,12 @@ repositories {
maven {
url "https://raw.github.com/signalapp/maven/master/circular-progress-button/releases/"
}
maven {
url "https://raw.github.com/signalapp/maven/master/circular-progress-button/releases/"
}
maven {
url "https://raw.github.com/signalapp/maven/master/sqlcipher/release/"
}
maven {
url "https://maven.google.com"
}
@ -119,7 +125,7 @@ dependencies {
}
compile 'com.codewaves.stickyheadergrid:stickyheadergrid:0.9.4'
compile 'com.github.dmytrodanylyk.circular-progress-button:library:1.1.3-S2'
compile 'net.zetetic:android-database-sqlcipher:3.5.9'
compile 'org.signal:android-database-sqlcipher:3.5.9-S1'
compile ('com.googlecode.ez-vcard:ez-vcard:0.9.11') {
exclude group: 'com.fasterxml.jackson.core'
exclude group: 'org.freemarker'
@ -192,7 +198,8 @@ dependencyVerification {
'com.annimon:stream:5da6e2e3e0551d61a3ea7014f04312276549e3dd739cf637996e4cf43c5535b9',
'com.takisoft.fix:colorpicker:f5d0dbabe406a1800498ca9c1faf34db36e021d8488bf10360f29961fe3ab0d1',
'com.github.dmytrodanylyk.circular-progress-button:library:8dc6a29a5a8db7b2ad5a9a7fda1dc9ae0893f4c8f0545732b2c63854ea693e8e',
'net.zetetic:android-database-sqlcipher:eff93b3222f4bdc349ffee2d2e3b2a2507241f17435fb998947bcce486618f1d',
'org.signal:android-database-sqlcipher:4302551df258883cc5dc5d62ddb141a6b5b8f113d77d70322dc2648c0856ccef',
'com.googlecode.ez-vcard:ez-vcard:7e24ad50b222d2f70ac91bdccfa3c0f6200b078d797cb784837f75e77bb4210f',
'com.google.android.gms:play-services-iid:54e919f9957b8b7820da7ee9b83471d00d0cac1cf08ddea8b5b41aea80bb1a70',
'com.google.android.gms:play-services-base:0ca636a8fc9a5af45e607cdcd61783bf5d561cbbb0f862021ce69606eee5ad49',
'com.google.android.gms:play-services-tasks:69ec265168e601d0203d04cd42e34bb019b2f029aa1e16fabd38a5153eea2086',
@ -223,6 +230,7 @@ dependencyVerification {
'javax.inject:javax.inject:91c77044a50c481636c32d916fd89c9118a72195390452c81065080f957de7ff',
'com.klinkerapps:logger:177e325259a8b111ad6745ec10db5861723c99f402222b80629f576f49408541',
'com.google.android:flexbox:a9989fd13ae2ee42765dfc515fe362edf4f326e74925d02a10369df8092a4935',
'org.jsoup:jsoup:abeaf34795a4de70f72aed6de5966d2955ec7eb348eeb813324f23c999575473',
'org.whispersystems:curve25519-android:82595394422b957d4a5b5f1b27b75ba25cf6dc4db4d312418ca38cd6fff279ca',
'org.whispersystems:signal-protocol-java:5152c2b01a25147967d6bf82e540f947901bdfa79260be3eb3e96b03f787d6b5',
'com.google.protobuf:protobuf-java:e0c1c64575c005601725e7c6a02cebf9e1285e888f756b2a1d73ffa8d725cc74',

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle" >
<solid android:color="@color/grey_200"/>
</shape>
</item>
<item android:top="1dp">
<shape android:shape="rectangle" >
<solid android:color="@color/white" />
</shape>
</item>
</layer-list>

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/search_no_results"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:background="@color/white"
android:visibility="gone" />
<android.support.v7.widget.RecyclerView
android:id="@+id/search_list"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
</FrameLayout>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
style="@style/Base.TextAppearance.AppCompat.Body2"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="8dp"
android:background="@drawable/header_search_background"
tools:text="Conversations">
</TextView>

View File

@ -1,9 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<Button xmlns:android="http://schemas.android.com/apk/res/android"
<ViewSwitcher
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:wheel="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?conversation_item_bubble_background"
android:padding="16dp"
android:elevation="2dp">
<TextView
style="@style/Base.TextAppearance.AppCompat.Button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:background="?conversation_item_bubble_background"
android:gravity="center"
android:textColor="?conversation_item_sent_text_primary_color"
android:text="@string/load_more_header__see_full_conversation" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_gravity="center">
<TextView
style="@style/Base.TextAppearance.AppCompat.Button"
android:id="@+id/load_more_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:textColor="?conversation_item_sent_text_primary_color"
android:text="@string/load_more_header__loading" />
<com.pnikosis.materialishprogress.ProgressWheel
android:id="@+id/load_more_progress"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_gravity="center_vertical"
android:layout_marginStart="11dp"
android:layout_marginLeft="11dp"
wheel:matProg_progressIndeterminate="true"
tools:ignore="RtlSymmetry" />
</LinearLayout>
</ViewSwitcher>

View File

@ -584,6 +584,13 @@
<string name="RingtonePreference_add_ringtone_text">Add ringtone</string>
<string name="RingtonePreference_unable_to_add_ringtone">Unable to add custom ringtone</string>
<!-- Search -->
<string name="SearchFragment_begin_searching">Begin typing to search for conversations, messages, and contacts.</string>
<string name="SearchFragment_no_results">No results found for \'%s\'</string>
<string name="SearchFragment_header_conversations">Conversations</string>
<string name="SearchFragment_header_contacts">Contacts</string>
<string name="SearchFragment_header_messages">Messages</string>
<!-- SharedContactDetailsActivity -->
<string name="SharedContactDetailsActivity_add_to_contacts">Add to Contacts</string>
<string name="SharedContactDetailsActivity_invite_to_signal">Invite to Signal</string>
@ -980,6 +987,7 @@
<!-- load_more_header -->
<string name="load_more_header__see_full_conversation">See full conversation</string>
<string name="load_more_header__loading">Loading</string>
<!-- media_overview_activity -->
<string name="media_overview_activity__no_media">No media</string>

View File

@ -210,6 +210,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
public static final String DISTRIBUTION_TYPE_EXTRA = "distribution_type";
public static final String TIMING_EXTRA = "timing";
public static final String LAST_SEEN_EXTRA = "last_seen";
public static final String STARTING_POSITION_EXTRA = "starting_position";
private static final int PICK_GALLERY = 1;
private static final int PICK_DOCUMENT = 2;

View File

@ -51,6 +51,7 @@ import android.view.animation.Animation;
import android.view.animation.AnimationUtils;
import android.widget.TextView;
import android.widget.Toast;
import android.widget.ViewSwitcher;
import org.thoughtcrime.securesms.ConversationAdapter.HeaderViewHolder;
import org.thoughtcrime.securesms.ConversationAdapter.ItemClickListener;
@ -90,10 +91,12 @@ import java.util.Set;
public class ConversationFragment extends Fragment
implements LoaderManager.LoaderCallbacks<Cursor>
{
private static final String TAG = ConversationFragment.class.getSimpleName();
private static final String TAG = ConversationFragment.class.getSimpleName();
private static final String KEY_LIMIT = "limit";
private static final long PARTIAL_CONVERSATION_LIMIT = 500L;
private static final int CODE_ADD_EDIT_CONTACT = 77;
private static final int PARTIAL_CONVERSATION_LIMIT = 500;
private static final int SCROLL_ANIMATION_THRESHOLD = 50;
private static final int CODE_ADD_EDIT_CONTACT = 77;
private final ActionModeCallback actionModeCallback = new ActionModeCallback();
private final ItemClickListener selectionClickListener = new ConversationFragmentItemClickListener();
@ -103,12 +106,16 @@ public class ConversationFragment extends Fragment
private Recipient recipient;
private long threadId;
private long lastSeen;
private int startingPosition;
private int previousOffset;
private boolean firstLoad;
private long loaderStartTime;
private ActionMode actionMode;
private Locale locale;
private RecyclerView list;
private RecyclerView.ItemDecoration lastSeenDecoration;
private View loadMoreView;
private ViewSwitcher topLoadMoreView;
private ViewSwitcher bottomLoadMoreView;
private UnknownSenderView unknownSenderView;
private View composeDivider;
private View scrollToBottomButton;
@ -135,12 +142,10 @@ public class ConversationFragment extends Fragment
list.setLayoutManager(layoutManager);
list.setItemAnimator(null);
loadMoreView = inflater.inflate(R.layout.load_more_header, container, false);
loadMoreView.setOnClickListener(v -> {
Bundle args = new Bundle();
args.putLong("limit", 0);
getLoaderManager().restartLoader(0, args, ConversationFragment.this);
});
topLoadMoreView = (ViewSwitcher) inflater.inflate(R.layout.load_more_header, container, false);
bottomLoadMoreView = (ViewSwitcher) inflater.inflate(R.layout.load_more_header, container, false);
initializeLoadMoreView(topLoadMoreView);
initializeLoadMoreView(bottomLoadMoreView);
return view;
}
@ -189,6 +194,7 @@ public class ConversationFragment extends Fragment
this.recipient = Recipient.from(getActivity(), getActivity().getIntent().getParcelableExtra(ConversationActivity.ADDRESS_EXTRA), true);
this.threadId = this.getActivity().getIntent().getLongExtra(ConversationActivity.THREAD_ID_EXTRA, -1);
this.lastSeen = this.getActivity().getIntent().getLongExtra(ConversationActivity.LAST_SEEN_EXTRA, -1);
this.startingPosition = this.getActivity().getIntent().getIntExtra(ConversationActivity.STARTING_POSITION_EXTRA, -1);
this.firstLoad = true;
this.unknownSenderView = new UnknownSenderView(getActivity(), recipient, threadId);
@ -207,6 +213,16 @@ public class ConversationFragment extends Fragment
}
}
private void initializeLoadMoreView(ViewSwitcher loadMoreView) {
loadMoreView.setOnClickListener(v -> {
Bundle args = new Bundle();
args.putInt(KEY_LIMIT, 0);
getLoaderManager().restartLoader(0, args, ConversationFragment.this);
loadMoreView.showNext();
loadMoreView.setOnClickListener(null);
});
}
private void setCorrectMenuVisibility(Menu menu) {
Set<MessageRecord> messageRecords = getListAdapter().getSelectedItems();
boolean actionMessage = false;
@ -278,7 +294,11 @@ public class ConversationFragment extends Fragment
}
public void scrollToBottom() {
list.smoothScrollToPosition(0);
if (((LinearLayoutManager) list.getLayoutManager()).findFirstVisibleItemPosition() < SCROLL_ANIMATION_THRESHOLD) {
list.smoothScrollToPosition(0);
} else {
list.scrollToPosition(0);
}
}
public void setLastSeen(long lastSeen) {
@ -424,43 +444,76 @@ public class ConversationFragment extends Fragment
@Override
public Loader<Cursor> onCreateLoader(int id, Bundle args) {
return new ConversationLoader(getActivity(), threadId, args.getLong("limit", PARTIAL_CONVERSATION_LIMIT), lastSeen);
Log.w(TAG, "onCreateLoader");
loaderStartTime = System.currentTimeMillis();
int limit = args.getInt(KEY_LIMIT, PARTIAL_CONVERSATION_LIMIT);
int offset = 0;
if (limit != 0 && startingPosition > limit) {
offset = Math.max(startingPosition - (limit / 2) + 1, 0);
startingPosition -= offset - 1;
}
return new ConversationLoader(getActivity(), threadId, offset, limit, lastSeen);
}
@Override
public void onLoadFinished(Loader<Cursor> cursorLoader, Cursor cursor) {
Log.w(TAG, "onLoadFinished");
long loadTime = System.currentTimeMillis() - loaderStartTime;
int count = cursor.getCount();
Log.w(TAG, "onLoadFinished - took " + loadTime + " ms to load a cursor of size " + count);
ConversationLoader loader = (ConversationLoader)cursorLoader;
if (list.getAdapter() != null) {
if (cursor.getCount() >= PARTIAL_CONVERSATION_LIMIT && loader.hasLimit()) {
getListAdapter().setFooterView(loadMoreView);
ConversationAdapter adapter = getListAdapter();
if (adapter == null) {
return;
}
if (cursor.getCount() >= PARTIAL_CONVERSATION_LIMIT && loader.hasLimit()) {
adapter.setFooterView(topLoadMoreView);
} else {
adapter.setFooterView(null);
}
if (lastSeen == -1) {
setLastSeen(loader.getLastSeen());
}
if (!loader.hasSent() && !recipient.isSystemContact() && !recipient.isGroupRecipient() && recipient.getRegistered() == RecipientDatabase.RegisteredState.REGISTERED) {
adapter.setHeaderView(unknownSenderView);
} else {
adapter.setHeaderView(null);
}
if (loader.hasOffset()) {
adapter.setHeaderView(bottomLoadMoreView);
previousOffset = loader.getOffset();
}
adapter.changeCursor(cursor);
int lastSeenPosition = adapter.findLastSeenPosition(lastSeen);
if (firstLoad) {
if (startingPosition >= 0) {
scrollToStartingPosition(startingPosition);
} else {
getListAdapter().setFooterView(null);
}
if (lastSeen == -1) {
setLastSeen(loader.getLastSeen());
}
if (!loader.hasSent() && !recipient.isSystemContact() && !recipient.isGroupRecipient() && recipient.getRegistered() == RecipientDatabase.RegisteredState.REGISTERED) {
getListAdapter().setHeaderView(unknownSenderView);
} else {
getListAdapter().setHeaderView(null);
}
getListAdapter().changeCursor(cursor);
int lastSeenPosition = getListAdapter().findLastSeenPosition(lastSeen);
if (firstLoad) {
scrollToLastSeenPosition(lastSeenPosition);
firstLoad = false;
}
firstLoad = false;
} else if (previousOffset > 0) {
int scrollPosition = previousOffset + ((LinearLayoutManager) list.getLayoutManager()).findFirstVisibleItemPosition();
scrollPosition = Math.min(scrollPosition, count - 1);
if (lastSeenPosition <= 0) {
setLastSeen(0);
}
View firstView = list.getLayoutManager().getChildAt(scrollPosition);
int pixelOffset = (firstView == null) ? 0 : (firstView.getBottom() - list.getPaddingBottom());
((LinearLayoutManager) list.getLayoutManager()).scrollToPositionWithOffset(scrollPosition, pixelOffset);
previousOffset = 0;
}
if (lastSeenPosition <= 0) {
setLastSeen(0);
}
}
@ -501,6 +554,13 @@ public class ConversationFragment extends Fragment
}
}
private void scrollToStartingPosition(final int startingPosition) {
list.post(() -> {
list.getLayoutManager().scrollToPosition(startingPosition);
getListAdapter().pulseHighlightItem(startingPosition);
});
}
private void scrollToLastSeenPosition(final int lastSeenPosition) {
if (lastSeenPosition > 0) {
list.post(() -> ((LinearLayoutManager)list.getLayoutManager()).scrollToPositionWithOffset(lastSeenPosition, list.getHeight()));

View File

@ -25,9 +25,12 @@ import android.net.Uri;
import android.os.AsyncTask;
import android.os.Bundle;
import android.support.v7.widget.Toolbar;
import android.text.TextUtils;
import android.util.Log;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.Toast;
@ -40,6 +43,7 @@ import org.thoughtcrime.securesms.notifications.MarkReadReceiver;
import org.thoughtcrime.securesms.notifications.MessageNotifier;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.search.SearchFragment;
import org.thoughtcrime.securesms.service.KeyCachingService;
import org.thoughtcrime.securesms.util.DynamicLanguage;
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
@ -57,9 +61,11 @@ public class ConversationListActivity extends PassphraseRequiredActionBarActivit
private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme();
private final DynamicLanguage dynamicLanguage = new DynamicLanguage();
private ConversationListFragment fragment;
private ConversationListFragment conversationListFragment;
private SearchFragment searchFragment;
private SearchToolbar searchToolbar;
private ImageView searchAction;
private ViewGroup fragmentContainer;
@Override
protected void onPreCreate() {
@ -74,9 +80,10 @@ public class ConversationListActivity extends PassphraseRequiredActionBarActivit
Toolbar toolbar = findViewById(R.id.toolbar);
setSupportActionBar(toolbar);
searchToolbar = findViewById(R.id.search_toolbar);
searchAction = findViewById(R.id.search_action);
fragment = initFragment(R.id.fragment_container, new ConversationListFragment(), dynamicLanguage.getCurrentLocale());
searchToolbar = findViewById(R.id.search_toolbar);
searchAction = findViewById(R.id.search_action);
fragmentContainer = findViewById(R.id.fragment_container);
conversationListFragment = initFragment(R.id.fragment_container, new ConversationListFragment(), dynamicLanguage.getCurrentLocale());
initializeSearchListener();
@ -123,15 +130,31 @@ public class ConversationListActivity extends PassphraseRequiredActionBarActivit
searchToolbar.setListener(new SearchToolbar.SearchListener() {
@Override
public void onSearchTextChange(String text) {
if (fragment != null) {
fragment.setQueryFilter(text);
String trimmed = text.trim();
if (trimmed.length() > 0) {
if (searchFragment == null) {
searchFragment = SearchFragment.newInstance(dynamicLanguage.getCurrentLocale());
getSupportFragmentManager().beginTransaction()
.add(R.id.fragment_container, searchFragment, null)
.commit();
}
searchFragment.updateSearchQuery(trimmed);
} else if (searchFragment != null) {
getSupportFragmentManager().beginTransaction()
.remove(searchFragment)
.commit();
searchFragment = null;
}
}
@Override
public void onSearchReset() {
if (fragment != null) {
fragment.resetQueryFilter();
public void onSearchClosed() {
if (searchFragment != null) {
getSupportFragmentManager().beginTransaction()
.remove(searchFragment)
.commit();
searchFragment = null;
}
}
});
@ -156,12 +179,19 @@ public class ConversationListActivity extends PassphraseRequiredActionBarActivit
@Override
public void onCreateConversation(long threadId, Recipient recipient, int distributionType, long lastSeen) {
openConversation(threadId, recipient, distributionType, lastSeen, -1);
}
public void openConversation(long threadId, Recipient recipient, int distributionType, long lastSeen, int startingPosition) {
searchToolbar.clearFocus();
Intent intent = new Intent(this, ConversationActivity.class);
intent.putExtra(ConversationActivity.ADDRESS_EXTRA, recipient.getAddress());
intent.putExtra(ConversationActivity.THREAD_ID_EXTRA, threadId);
intent.putExtra(ConversationActivity.DISTRIBUTION_TYPE_EXTRA, distributionType);
intent.putExtra(ConversationActivity.TIMING_EXTRA, System.currentTimeMillis());
intent.putExtra(ConversationActivity.LAST_SEEN_EXTRA, lastSeen);
intent.putExtra(ConversationActivity.STARTING_POSITION_EXTRA, startingPosition);
startActivity(intent);
overridePendingTransition(R.anim.slide_from_right, R.anim.fade_scale_out);

View File

@ -24,6 +24,11 @@ import android.graphics.drawable.RippleDrawable;
import android.os.Build.VERSION;
import android.os.Build.VERSION_CODES;
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.style.StyleSpan;
import android.util.AttributeSet;
import android.view.View;
import android.widget.ImageView;
@ -41,10 +46,12 @@ import org.thoughtcrime.securesms.database.model.ThreadRecord;
import org.thoughtcrime.securesms.mms.GlideRequests;
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.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
import java.util.Collections;
import java.util.Locale;
import java.util.Set;
@ -57,8 +64,9 @@ public class ConversationListItem extends RelativeLayout
@SuppressWarnings("unused")
private final static String TAG = ConversationListItem.class.getSimpleName();
private final static Typeface BOLD_TYPEFACE = Typeface.create("sans-serif", Typeface.BOLD);
private final static Typeface LIGHT_TYPEFACE = Typeface.create("sans-serif-light", Typeface.NORMAL);
private final static Typeface BOLD_TYPEFACE = Typeface.create("sans-serif", Typeface.BOLD);
private final static Typeface LIGHT_TYPEFACE = Typeface.create("sans-serif-light", Typeface.NORMAL);
private final static StyleSpan BOLD_SPAN = new StyleSpan(Typeface.BOLD);
private Set<Long> selectedThreads;
private Recipient recipient;
@ -107,8 +115,20 @@ public class ConversationListItem extends RelativeLayout
@Override
public void bind(@NonNull ThreadRecord thread,
@NonNull GlideRequests glideRequests, @NonNull Locale locale,
@NonNull Set<Long> selectedThreads, boolean batchMode)
@NonNull GlideRequests glideRequests,
@NonNull Locale locale,
@NonNull Set<Long> selectedThreads,
boolean batchMode)
{
bind(thread, glideRequests, locale, selectedThreads, batchMode, null);
}
public void bind(@NonNull ThreadRecord thread,
@NonNull GlideRequests glideRequests,
@NonNull Locale locale,
@NonNull Set<Long> selectedThreads,
boolean batchMode,
@Nullable String highlightSubstring)
{
this.selectedThreads = selectedThreads;
this.recipient = thread.getRecipient();
@ -119,7 +139,11 @@ public class ConversationListItem extends RelativeLayout
this.lastSeen = thread.getLastSeen();
this.recipient.addListener(this);
this.fromView.setText(recipient, unreadCount == 0);
if (highlightSubstring != null) {
this.fromView.setText(getHighlightedSpan(locale, recipient.getName(), highlightSubstring));
} else {
this.fromView.setText(recipient, unreadCount == 0);
}
this.subjectView.setText(thread.getDisplayBody());
// this.subjectView.setTypeface(read ? LIGHT_TYPEFACE : BOLD_TYPEFACE);
@ -144,6 +168,56 @@ public class ConversationListItem extends RelativeLayout
this.contactPhotoImage.setAvatar(glideRequests, recipient, true);
}
public void bind(@NonNull Recipient contact,
@NonNull GlideRequests glideRequests,
@NonNull Locale locale,
@Nullable String highlightSubstring)
{
this.selectedThreads = Collections.emptySet();
this.recipient = contact;
this.glideRequests = glideRequests;
this.recipient.addListener(this);
fromView.setText(getHighlightedSpan(locale, recipient.getName(), highlightSubstring));
subjectView.setText(contact.getAddress().toPhoneString());
dateView.setText("");
archivedView.setVisibility(GONE);
unreadIndicator.setVisibility(GONE);
deliveryStatusIndicator.setNone();
alertView.setNone();
thumbnailView.setVisibility(GONE);
setBatchState(false);
setRippleColor(contact);
contactPhotoImage.setAvatar(glideRequests, recipient, true);
}
public void bind(@NonNull MessageResult messageResult,
@NonNull GlideRequests glideRequests,
@NonNull Locale locale,
@Nullable String highlightSubstring)
{
this.selectedThreads = Collections.emptySet();
this.recipient = messageResult.recipient;
this.glideRequests = glideRequests;
this.recipient.addListener(this);
fromView.setText(recipient, true);
subjectView.setText(getHighlightedSpan(locale, messageResult.bodySnippet, highlightSubstring));
dateView.setText(DateUtils.getBriefRelativeTimeSpanString(getContext(), locale, messageResult.receivedTimestampMs));
archivedView.setVisibility(GONE);
unreadIndicator.setVisibility(GONE);
deliveryStatusIndicator.setNone();
alertView.setNone();
thumbnailView.setVisibility(GONE);
setBatchState(false);
setRippleColor(recipient);
contactPhotoImage.setAvatar(glideRequests, recipient, true);
}
@Override
public void unbind() {
if (this.recipient != null) this.recipient.removeListener(this);
@ -241,6 +315,26 @@ public class ConversationListItem extends RelativeLayout
unreadIndicator.setVisibility(View.VISIBLE);
}
private Spanned getHighlightedSpan(@NonNull Locale locale,
@Nullable String value,
@Nullable String highlight)
{
if (value == null || highlight == null) {
return new SpannableString(value);
}
int startPosition = value.toLowerCase(locale).indexOf(highlight.toLowerCase());
int endPosition = startPosition + highlight.length();
if (startPosition < 0) {
return new SpannableString(value);
}
Spannable spanned = new SpannableString(value);
spanned.setSpan(BOLD_SPAN, startPosition, endPosition, Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
return spanned;
}
@Override
public void onModified(final Recipient recipient) {
Util.runOnMain(() -> {

View File

@ -81,6 +81,7 @@ public class DatabaseUpgradeActivity extends BaseActivity {
public static final int SQLCIPHER_COMPLETE = 352;
public static final int REMOVE_JOURNAL = 353;
public static final int REMOVE_CACHE = 354;
public static final int FULL_TEXT_SEARCH = 358;
private static final SortedSet<Integer> UPGRADE_VERSIONS = new TreeSet<Integer>() {{
add(NO_MORE_KEY_EXCHANGE_PREFIX_VERSION);
@ -101,6 +102,7 @@ public class DatabaseUpgradeActivity extends BaseActivity {
add(SQLCIPHER);
add(SQLCIPHER_COMPLETE);
add(REMOVE_CACHE);
add(FULL_TEXT_SEARCH);
}};
private MasterSecret masterSecret;

View File

@ -175,7 +175,7 @@ public class ShareActivity extends PassphraseRequiredActionBarActivity
}
@Override
public void onSearchReset() {
public void onSearchClosed() {
if (contactsFragment != null) {
contactsFragment.resetQueryFilter();
}

View File

@ -120,7 +120,8 @@ public class SearchToolbar extends LinearLayout {
private void hide() {
if (getVisibility() == View.VISIBLE) {
if (listener != null) listener.onSearchReset();
if (listener != null) listener.onSearchClosed();
if (Build.VERSION.SDK_INT >= 21) {
Animator animator = ViewAnimationUtils.createCircularReveal(this, (int)x, (int)y, getWidth(), 0);
@ -149,7 +150,7 @@ public class SearchToolbar extends LinearLayout {
public interface SearchListener {
void onSearchTextChange(String text);
void onSearchReset();
void onSearchClosed();
}
}

View File

@ -145,7 +145,7 @@ public class ContactsDatabase {
}
@SuppressLint("Recycle")
@NonNull Cursor querySystemContacts(@Nullable String filter) {
public @NonNull Cursor querySystemContacts(@Nullable String filter) {
Uri uri;
if (!TextUtils.isEmpty(filter)) {
@ -193,7 +193,7 @@ public class ContactsDatabase {
}
@SuppressLint("Recycle")
@NonNull Cursor queryTextSecureContacts(String filter) {
public @NonNull Cursor queryTextSecureContacts(String filter) {
String[] projection = new String[] {ContactsContract.Contacts.DISPLAY_NAME,
ContactsContract.Data.DATA1};

View File

@ -0,0 +1,191 @@
package org.thoughtcrime.securesms.database;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.support.annotation.NonNull;
import java.io.Closeable;
import java.util.Collection;
import java.util.Iterator;
import java.util.List;
import java.util.ListIterator;
/**
* A list backed by a {@link Cursor} that retrieves models using a provided {@link ModelBuilder}.
* Allows you to abstract away the use of a {@link Cursor} while still getting the benefits of a
* {@link Cursor} (e.g. windowing).
*
* The one special consideration that must be made is that because this contains a cursor, you must
* call {@link #close()} when you are finished with it.
*
* Given that this is cursor-backed, it is effectively immutable.
*/
public class CursorList<T> implements List<T>, Closeable {
private static final Cursor EMPTY_CURSOR = new MatrixCursor(new String[] { "a" }, 0);
private final Cursor cursor;
private final ModelBuilder<T> modelBuilder;
public CursorList(@NonNull Cursor cursor, @NonNull ModelBuilder<T> modelBuilder) {
this.cursor = cursor;
this.modelBuilder = modelBuilder;
this.cursor.moveToFirst();
}
public static <T> CursorList<T> emptyList() {
//noinspection ConstantConditions,unchecked
return (CursorList<T>) new CursorList(EMPTY_CURSOR, null);
}
@Override
public int size() {
return cursor.getCount();
}
@Override
public boolean isEmpty() {
return size() == 0;
}
@Override
public boolean contains(Object o) {
throw new UnsupportedOperationException();
}
@NonNull
@Override
public Iterator<T> iterator() {
return new Iterator<T>() {
@Override
public boolean hasNext() {
return cursor.getCount() > 0 && !cursor.isLast();
}
@Override
public T next() {
T model = modelBuilder.build(cursor);
cursor.moveToNext();
return model;
}
};
}
@NonNull
@Override
public Object[] toArray() {
Object[] out = new Object[size()];
for (int i = 0; i < cursor.getCount(); i++) {
cursor.moveToPosition(i);
out[i] = modelBuilder.build(cursor);
}
return out;
}
@Override
public boolean add(T o) {
throw new UnsupportedOperationException();
}
@Override
public boolean remove(Object o) {
throw new UnsupportedOperationException();
}
@Override
public boolean addAll(@NonNull Collection collection) {
throw new UnsupportedOperationException();
}
@Override
public boolean addAll(int i, @NonNull Collection collection) {
throw new UnsupportedOperationException();
}
@Override
public void clear() {
throw new UnsupportedOperationException();
}
@Override
public T get(int i) {
cursor.moveToPosition(i);
return modelBuilder.build(cursor);
}
@Override
public T set(int i, T o) {
throw new UnsupportedOperationException();
}
@Override
public void add(int i, T o) {
throw new UnsupportedOperationException();
}
@Override
public T remove(int i) {
throw new UnsupportedOperationException();
}
@Override
public int indexOf(Object o) {
throw new UnsupportedOperationException();
}
@Override
public int lastIndexOf(Object o) {
throw new UnsupportedOperationException();
}
@Override
public ListIterator<T> listIterator() {
throw new UnsupportedOperationException();
}
@NonNull
@Override
public ListIterator<T> listIterator(int i) {
throw new UnsupportedOperationException();
}
@NonNull
@Override
public List<T> subList(int i, int i1) {
throw new UnsupportedOperationException();
}
@Override
public boolean retainAll(@NonNull Collection collection) {
throw new UnsupportedOperationException();
}
@Override
public boolean removeAll(@NonNull Collection collection) {
throw new UnsupportedOperationException();
}
@Override
public boolean containsAll(@NonNull Collection collection) {
throw new UnsupportedOperationException();
}
@NonNull
@Override
public T[] toArray(@NonNull Object[] objects) {
throw new UnsupportedOperationException();
}
@Override
public void close() {
if (cursor != null) {
cursor.close();
}
}
public interface ModelBuilder<T> {
T build(@NonNull Cursor cursor);
}
}

View File

@ -56,6 +56,7 @@ public class DatabaseFactory {
private final OneTimePreKeyDatabase preKeyDatabase;
private final SignedPreKeyDatabase signedPreKeyDatabase;
private final SessionDatabase sessionDatabase;
private final SearchDatabase searchDatabase;
public static DatabaseFactory getInstance(Context context) {
synchronized (lock) {
@ -130,6 +131,10 @@ public class DatabaseFactory {
return getInstance(context).sessionDatabase;
}
public static SearchDatabase getSearchDatabase(Context context) {
return getInstance(context).searchDatabase;
}
public static SQLiteDatabase getBackupDatabase(Context context) {
return getInstance(context).databaseHelper.getReadableDatabase();
}
@ -162,6 +167,7 @@ public class DatabaseFactory {
this.preKeyDatabase = new OneTimePreKeyDatabase(context, databaseHelper);
this.signedPreKeyDatabase = new SignedPreKeyDatabase(context, databaseHelper);
this.sessionDatabase = new SessionDatabase(context, databaseHelper);
this.searchDatabase = new SearchDatabase(context, databaseHelper);
}
public void onApplicationLevelUpgrade(@NonNull Context context, @NonNull MasterSecret masterSecret,

View File

@ -93,18 +93,19 @@ public class MmsSmsDatabase extends Database {
return null;
}
public Cursor getConversation(long threadId, long limit) {
public Cursor getConversation(long threadId, long offset, long limit) {
String order = MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " DESC";
String selection = MmsSmsColumns.THREAD_ID + " = " + threadId;
String limitStr = limit > 0 || offset > 0 ? offset + ", " + limit : null;
Cursor cursor = queryTables(PROJECTION, selection, order, limit > 0 ? String.valueOf(limit) : null);
Cursor cursor = queryTables(PROJECTION, selection, order, limitStr);
setNotifyConverationListeners(cursor, threadId);
return cursor;
}
public Cursor getConversation(long threadId) {
return getConversation(threadId, 0);
return getConversation(threadId, 0, 0);
}
public Cursor getIdentityConflictMessagesForThread(long threadId) {
@ -179,6 +180,26 @@ public class MmsSmsDatabase extends Database {
return -1;
}
/**
* Retrieves the position of the message with the provided timestamp in the query results you'd
* get from calling {@link #getConversation(long)}.
*
* Note: This could give back incorrect results in the situation where multiple messages have the
* same received timestamp. However, because this was designed to determine where to scroll to,
* you'll still wind up in about the right spot.
*/
public int getMessagePositionInConversation(long threadId, long receivedTimestamp) {
String order = MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " DESC";
String selection = MmsSmsColumns.THREAD_ID + " = " + threadId + " AND " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " > " + receivedTimestamp;
try (Cursor cursor = queryTables(new String[]{ "COUNT(*)" }, selection, order, null)) {
if (cursor != null && cursor.moveToFirst()) {
return cursor.getInt(0);
}
}
return -1;
}
private Cursor queryTables(String[] projection, String selection, String order, String limit) {
String[] mmsProjection = {MmsDatabase.DATE_SENT + " AS " + MmsSmsColumns.NORMALIZED_DATE_SENT,
MmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED,

View File

@ -0,0 +1,89 @@
package org.thoughtcrime.securesms.database;
import android.content.Context;
import android.support.annotation.NonNull;
import android.util.Log;
import net.sqlcipher.Cursor;
import net.sqlcipher.database.SQLiteDatabase;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
/**
* Contains all databases necessary for full-text search (FTS).
*/
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[] CREATE_TABLE = {
"CREATE VIRTUAL TABLE " + SMS_FTS_TABLE_NAME + " USING fts5(" + BODY + ", 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" +
"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" +
"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" +
"END;",
"CREATE VIRTUAL TABLE " + MMS_FTS_TABLE_NAME + " USING fts5(" + BODY + ", 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" +
"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" +
"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" +
"END;"
};
private static final String MESSAGES_QUERY =
"SELECT " +
MmsSmsColumns.ADDRESS + ", " +
"snippet(" + SMS_FTS_TABLE_NAME + ", -1, '', '', '...', 7) AS " + SNIPPET + ", " +
SmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + ", " +
MmsSmsColumns.THREAD_ID + ", " +
"bm25(" + SMS_FTS_TABLE_NAME + ") AS " + RANK + " " +
"FROM " + SmsDatabase.TABLE_NAME + " " +
"INNER JOIN " + SMS_FTS_TABLE_NAME + " ON " + SMS_FTS_TABLE_NAME + "." + ID + " = " + SmsDatabase.TABLE_NAME + "." + SmsDatabase.ID + " " +
"WHERE " + SMS_FTS_TABLE_NAME + " MATCH ? " +
"UNION ALL " +
"SELECT " +
MmsSmsColumns.ADDRESS + ", " +
"snippet(" + MMS_FTS_TABLE_NAME + ", -1, '', '', '...', 7) AS " + SNIPPET + ", " +
MmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + ", " +
MmsSmsColumns.THREAD_ID + ", " +
"bm25(" + MMS_FTS_TABLE_NAME + ") AS " + RANK + " " +
"FROM " + MmsDatabase.TABLE_NAME + " " +
"INNER JOIN " + MMS_FTS_TABLE_NAME + " ON " + MMS_FTS_TABLE_NAME + "." + ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " " +
"WHERE " + MMS_FTS_TABLE_NAME + " MATCH ? " +
"ORDER BY rank " +
"LIMIT 500";
public SearchDatabase(@NonNull Context context, @NonNull SQLCipherOpenHelper databaseHelper) {
super(context, databaseHelper);
}
public Cursor queryMessages(@NonNull String query) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
String prefixQuery = query + '*';
return db.rawQuery(MESSAGES_QUERY, new String[] { prefixQuery, prefixQuery });
}
}

View File

@ -46,6 +46,7 @@ import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.libsignal.util.Pair;
import org.whispersystems.libsignal.util.guava.Optional;
import java.io.Closeable;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
@ -625,7 +626,7 @@ public class ThreadDatabase extends Database {
public static final int INBOX_ZERO = 4;
}
public class Reader {
public class Reader implements Closeable {
private final Cursor cursor;
@ -692,8 +693,11 @@ public class ThreadDatabase extends Database {
}
}
@Override
public void close() {
cursor.close();
if (cursor != null) {
cursor.close();
}
}
}
}

View File

@ -4,6 +4,7 @@ package org.thoughtcrime.securesms.database.helpers;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.os.SystemClock;
import android.support.annotation.NonNull;
import android.util.Log;
@ -23,6 +24,7 @@ import org.thoughtcrime.securesms.database.MmsDatabase;
import org.thoughtcrime.securesms.database.OneTimePreKeyDatabase;
import org.thoughtcrime.securesms.database.PushDatabase;
import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.database.SearchDatabase;
import org.thoughtcrime.securesms.database.SessionDatabase;
import org.thoughtcrime.securesms.database.SignedPreKeyDatabase;
import org.thoughtcrime.securesms.database.SmsDatabase;
@ -45,8 +47,9 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
private static final int ATTACHMENT_DIMENSIONS = 6;
private static final int QUOTED_REPLIES = 7;
private static final int SHARED_CONTACTS = 8;
private static final int FULL_TEXT_SEARCH = 9;
private static final int DATABASE_VERSION = 8;
private static final int DATABASE_VERSION = 9;
private static final String DATABASE_NAME = "signal.db";
private final Context context;
@ -86,6 +89,9 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
db.execSQL(OneTimePreKeyDatabase.CREATE_TABLE);
db.execSQL(SignedPreKeyDatabase.CREATE_TABLE);
db.execSQL(SessionDatabase.CREATE_TABLE);
for (String sql : SearchDatabase.CREATE_TABLE) {
db.execSQL(sql);
}
executeStatements(db, SmsDatabase.CREATE_INDEXS);
executeStatements(db, MmsDatabase.CREATE_INDEXS);
@ -182,6 +188,28 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
db.execSQL("ALTER TABLE mms ADD COLUMN shared_contacts TEXT");
}
if (oldVersion < FULL_TEXT_SEARCH) {
for (String sql : SearchDatabase.CREATE_TABLE) {
db.execSQL(sql);
}
Log.i(TAG, "Beginning to build search index.");
long start = SystemClock.elapsedRealtime();
db.execSQL("INSERT INTO " + SearchDatabase.SMS_FTS_TABLE_NAME + " (rowid, " + SearchDatabase.BODY + ") " +
"SELECT " + SmsDatabase.ID + " , " + SmsDatabase.BODY + " FROM " + SmsDatabase.TABLE_NAME);
long smsFinished = SystemClock.elapsedRealtime();
Log.i(TAG, "Indexing SMS completed in " + (smsFinished - start) + " ms");
db.execSQL("INSERT INTO " + SearchDatabase.MMS_FTS_TABLE_NAME + " (rowid, " + SearchDatabase.BODY + ") " +
"SELECT " + MmsDatabase.ID + " , " + MmsDatabase.BODY + " FROM " + MmsDatabase.TABLE_NAME);
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

@ -9,13 +9,15 @@ import org.whispersystems.libsignal.util.Pair;
public class ConversationLoader extends AbstractCursorLoader {
private final long threadId;
private long limit;
private int offset;
private int limit;
private long lastSeen;
private boolean hasSent;
public ConversationLoader(Context context, long threadId, long limit, long lastSeen) {
public ConversationLoader(Context context, long threadId, int offset, int limit, long lastSeen) {
super(context);
this.threadId = threadId;
this.offset = offset;
this.limit = limit;
this.lastSeen = lastSeen;
this.hasSent = true;
@ -25,6 +27,14 @@ public class ConversationLoader extends AbstractCursorLoader {
return limit > 0;
}
public boolean hasOffset() {
return offset > 0;
}
public int getOffset() {
return offset;
}
public long getLastSeen() {
return lastSeen;
}
@ -43,6 +53,6 @@ public class ConversationLoader extends AbstractCursorLoader {
this.lastSeen = lastSeenAndHasSent.first();
}
return DatabaseFactory.getMmsSmsDatabase(context).getConversation(threadId, limit);
return DatabaseFactory.getMmsSmsDatabase(context).getConversation(threadId, offset, limit);
}
}

View File

@ -0,0 +1,178 @@
package org.thoughtcrime.securesms.search;
import android.annotation.SuppressLint;
import android.arch.lifecycle.ViewModelProviders;
import android.content.Intent;
import android.os.AsyncTask;
import android.os.Bundle;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v4.app.Fragment;
import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.RecyclerView;
import android.text.TextUtils;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import org.thoughtcrime.securesms.ConversationActivity;
import org.thoughtcrime.securesms.ConversationListActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.contacts.ContactAccessor;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.database.model.ThreadRecord;
import org.thoughtcrime.securesms.mms.GlideApp;
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.StickyHeaderDecoration;
import java.util.Locale;
import java.util.concurrent.Executors;
/**
* A fragment that is displayed to do full-text search of messages, groups, and contacts.
*/
public class SearchFragment extends Fragment implements SearchListAdapter.EventListener {
public static final String TAG = "SearchFragment";
public static final String EXTRA_LOCALE = "locale";
private TextView noResultsView;
private RecyclerView listView;
private SearchViewModel viewModel;
private SearchListAdapter listAdapter;
private String pendingQuery;
private Locale locale;
public static SearchFragment newInstance(@NonNull Locale locale) {
Bundle args = new Bundle();
args.putSerializable(EXTRA_LOCALE, locale);
SearchFragment fragment = new SearchFragment();
fragment.setArguments(args);
return fragment;
}
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
this.locale = (Locale) getArguments().getSerializable(EXTRA_LOCALE);
SearchRepository searchRepository = new SearchRepository(getContext(),
DatabaseFactory.getSearchDatabase(getContext()),
DatabaseFactory.getContactsDatabase(getContext()),
DatabaseFactory.getThreadDatabase(getContext()),
ContactAccessor.getInstance(),
Executors.newSingleThreadExecutor());
viewModel = ViewModelProviders.of(this, new SearchViewModel.Factory(searchRepository)).get(SearchViewModel.class);
if (pendingQuery != null) {
viewModel.updateQuery(pendingQuery);
pendingQuery = null;
}
}
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
return inflater.inflate(R.layout.fragment_search, container, false);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
noResultsView = view.findViewById(R.id.search_no_results);
listView = view.findViewById(R.id.search_list);
listAdapter = new SearchListAdapter(GlideApp.with(this), this, locale);
listView.setAdapter(listAdapter);
listView.setLayoutManager(new LinearLayoutManager(getContext()));
listView.addItemDecoration(new StickyHeaderDecoration(listAdapter, false, false));
}
@Override
public void onStart() {
super.onStart();
viewModel.getSearchResult().observe(this, result -> {
result = result != null ? result : SearchResult.EMPTY;
listAdapter.updateResults(result);
if (result.isEmpty()) {
if (TextUtils.isEmpty(viewModel.getLastQuery().trim())) {
noResultsView.setVisibility(View.GONE);
} else {
noResultsView.setVisibility(View.VISIBLE);
noResultsView.setText(getString(R.string.SearchFragment_no_results, viewModel.getLastQuery()));
}
} else {
noResultsView.setVisibility(View.VISIBLE);
noResultsView.setText("");
}
});
}
@Override
public void onConversationClicked(@NonNull ThreadRecord threadRecord) {
ConversationListActivity conversationList = (ConversationListActivity) getActivity();
if (conversationList != null) {
conversationList.onCreateConversation(threadRecord.getThreadId(),
threadRecord.getRecipient(),
threadRecord.getDistributionType(),
threadRecord.getLastSeen());
}
}
@Override
public void onContactClicked(@NonNull Recipient contact) {
Intent intent = new Intent(getContext(), ConversationActivity.class);
intent.putExtra(ConversationActivity.ADDRESS_EXTRA, contact.getAddress());
long existingThread = DatabaseFactory.getThreadDatabase(getContext()).getThreadIdIfExistsFor(contact);
intent.putExtra(ConversationActivity.THREAD_ID_EXTRA, existingThread);
intent.putExtra(ConversationActivity.DISTRIBUTION_TYPE_EXTRA, ThreadDatabase.DistributionTypes.DEFAULT);
startActivity(intent);
}
@SuppressLint("StaticFieldLeak")
@Override
public void onMessageClicked(@NonNull MessageResult message) {
new AsyncTask<Void, Void, Integer>() {
@Override
protected Integer doInBackground(Void... voids) {
int startingPosition = DatabaseFactory.getMmsSmsDatabase(getContext()).getMessagePositionInConversation(message.threadId, message.receivedTimestampMs);
startingPosition = Math.max(0, startingPosition);
return startingPosition;
}
@Override
protected void onPostExecute(Integer startingPosition) {
ConversationListActivity conversationList = (ConversationListActivity) getActivity();
if (conversationList != null) {
conversationList.openConversation(message.threadId,
message.recipient,
ThreadDatabase.DistributionTypes.DEFAULT,
-1,
startingPosition);
}
}
}.execute();
}
public void updateSearchQuery(@NonNull String query) {
if (viewModel != null) {
viewModel.updateQuery(query);
} else {
pendingQuery = query;
}
}
}

View File

@ -0,0 +1,219 @@
package org.thoughtcrime.securesms.search;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v7.widget.RecyclerView;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import org.thoughtcrime.securesms.ConversationListItem;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.database.model.ThreadRecord;
import org.thoughtcrime.securesms.mms.GlideRequests;
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.StickyHeaderDecoration;
import java.util.Collections;
import java.util.Locale;
class SearchListAdapter extends RecyclerView.Adapter<SearchListAdapter.SearchResultViewHolder>
implements StickyHeaderDecoration.StickyHeaderAdapter<SearchListAdapter.HeaderViewHolder>
{
private static final int TYPE_CONVERSATIONS = 1;
private static final int TYPE_CONTACTS = 2;
private static final int TYPE_MESSAGES = 3;
private final GlideRequests glideRequests;
private final EventListener eventListener;
private final Locale locale;
@NonNull
private SearchResult searchResult = SearchResult.EMPTY;
SearchListAdapter(@NonNull GlideRequests glideRequests,
@NonNull EventListener eventListener,
@NonNull Locale locale)
{
this.glideRequests = glideRequests;
this.eventListener = eventListener;
this.locale = locale;
}
@NonNull
@Override
public SearchResultViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
return new SearchResultViewHolder(LayoutInflater.from(parent.getContext())
.inflate(R.layout.conversation_list_item_view, parent, false));
}
@Override
public void onBindViewHolder(@NonNull SearchResultViewHolder holder, int position) {
ThreadRecord conversationResult = getConversationResult(position);
if (conversationResult != null) {
holder.bind(conversationResult, glideRequests, eventListener, locale, searchResult.getQuery());
return;
}
Recipient contactResult = getContactResult(position);
if (contactResult != null) {
holder.bind(contactResult, glideRequests, eventListener, locale, searchResult.getQuery());
return;
}
MessageResult messageResult = getMessageResult(position);
if (messageResult != null) {
holder.bind(messageResult, glideRequests, eventListener, locale, searchResult.getQuery());
}
}
@Override
public void onViewRecycled(SearchResultViewHolder holder) {
holder.recycle();
}
@Override
public int getItemCount() {
return searchResult.size();
}
@Override
public long getHeaderId(int position) {
if (getConversationResult(position) != null) {
return TYPE_CONVERSATIONS;
} else if (getContactResult(position) != null) {
return TYPE_CONTACTS;
} else {
return TYPE_MESSAGES;
}
}
@Override
public HeaderViewHolder onCreateHeaderViewHolder(ViewGroup parent) {
return new HeaderViewHolder(LayoutInflater.from(parent.getContext())
.inflate(R.layout.header_search_result, parent, false));
}
@Override
public void onBindHeaderViewHolder(HeaderViewHolder viewHolder, int position) {
viewHolder.bind((int) getHeaderId(position));
}
void updateResults(@NonNull SearchResult result) {
this.searchResult = result;
notifyDataSetChanged();
}
@Nullable
private ThreadRecord getConversationResult(int position) {
if (position < searchResult.getConversations().size()) {
return searchResult.getConversations().get(position);
}
return null;
}
@Nullable
private Recipient getContactResult(int position) {
if (position >= getFirstContactIndex() && position < getFirstMessageIndex()) {
return searchResult.getContacts().get(position - getFirstContactIndex());
}
return null;
}
@Nullable
private MessageResult getMessageResult(int position) {
if (position >= getFirstMessageIndex() && position < searchResult.size()) {
return searchResult.getMessages().get(position - getFirstMessageIndex());
}
return null;
}
private int getFirstContactIndex() {
return searchResult.getConversations().size();
}
private int getFirstMessageIndex() {
return getFirstContactIndex() + searchResult.getContacts().size();
}
public interface EventListener {
void onConversationClicked(@NonNull ThreadRecord threadRecord);
void onContactClicked(@NonNull Recipient contact);
void onMessageClicked(@NonNull MessageResult message);
}
static class SearchResultViewHolder extends RecyclerView.ViewHolder {
private final ConversationListItem root;
SearchResultViewHolder(View itemView) {
super(itemView);
root = (ConversationListItem) itemView;
}
void bind(@NonNull ThreadRecord conversationResult,
@NonNull GlideRequests glideRequests,
@NonNull EventListener eventListener,
@NonNull Locale locale,
@Nullable String query)
{
root.bind(conversationResult, glideRequests, locale, Collections.emptySet(), false, query);
root.setOnClickListener(view -> eventListener.onConversationClicked(conversationResult));
}
void bind(@NonNull Recipient contactResult,
@NonNull GlideRequests glideRequests,
@NonNull EventListener eventListener,
@NonNull Locale locale,
@Nullable String query)
{
root.bind(contactResult, glideRequests, locale, query);
root.setOnClickListener(view -> eventListener.onContactClicked(contactResult));
}
void bind(@NonNull MessageResult messageResult,
@NonNull GlideRequests glideRequests,
@NonNull EventListener eventListener,
@NonNull Locale locale,
@Nullable String query)
{
root.bind(messageResult, glideRequests, locale, query);
root.setOnClickListener(view -> eventListener.onMessageClicked(messageResult));
}
void recycle() {
root.unbind();
root.setOnClickListener(null);
}
}
public static class HeaderViewHolder extends RecyclerView.ViewHolder {
private TextView titleView;
public HeaderViewHolder(View itemView) {
super(itemView);
titleView = (TextView) itemView;
}
public void bind(int headerType) {
switch (headerType) {
case TYPE_CONVERSATIONS:
titleView.setText(R.string.SearchFragment_header_conversations);
break;
case TYPE_CONTACTS:
titleView.setText(R.string.SearchFragment_header_contacts);
break;
case TYPE_MESSAGES:
titleView.setText(R.string.SearchFragment_header_messages);
break;
}
}
}
}

View File

@ -0,0 +1,188 @@
package org.thoughtcrime.securesms.search;
import android.Manifest;
import android.content.Context;
import android.database.Cursor;
import android.database.DatabaseUtils;
import android.database.MergeCursor;
import android.support.annotation.NonNull;
import android.text.TextUtils;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.contacts.ContactAccessor;
import org.thoughtcrime.securesms.contacts.ContactsDatabase;
import org.thoughtcrime.securesms.database.Address;
import org.thoughtcrime.securesms.database.CursorList;
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.permissions.Permissions;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.search.model.MessageResult;
import org.thoughtcrime.securesms.search.model.SearchResult;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.Executor;
/**
* Manages data retrieval for search.
*/
class SearchRepository {
private static final Set<Character> BANNED_CHARACTERS = new HashSet<>();
static {
// Several ranges of invalid ASCII characters
for (int i = 33; i <= 47; i++) {
BANNED_CHARACTERS.add((char) i);
}
for (int i = 58; i <= 64; i++) {
BANNED_CHARACTERS.add((char) i);
}
for (int i = 91; i <= 96; i++) {
BANNED_CHARACTERS.add((char) i);
}
for (int i = 123; i <= 126; i++) {
BANNED_CHARACTERS.add((char) i);
}
}
private final Context context;
private final SearchDatabase searchDatabase;
private final ContactsDatabase contactsDatabase;
private final ThreadDatabase threadDatabase;
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)
{
this.context = context.getApplicationContext();
this.searchDatabase = searchDatabase;
this.contactsDatabase = contactsDatabase;
this.threadDatabase = threadDatabase;
this.contactAccessor = contactAccessor;
this.executor = executor;
}
void query(@NonNull String query, @NonNull Callback 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);
callback.onResult(new SearchResult(cleanQuery, contacts, conversations, messages));
});
}
private CursorList<Recipient> queryContacts(String query) {
if (!Permissions.hasAny(context, Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)) {
return CursorList.emptyList();
}
Cursor textSecureContacts = contactsDatabase.queryTextSecureContacts(query);
Cursor systemContacts = contactsDatabase.querySystemContacts(query);
MergeCursor contacts = new MergeCursor(new Cursor[]{ textSecureContacts, systemContacts });
return new CursorList<>(contacts, new RecipientModelBuilder(context));
}
private CursorList<ThreadRecord> queryConversations(@NonNull String query) {
List<String> numbers = contactAccessor.getNumbersForThreadSearchFilter(context, query);
List<Address> addresses = Stream.of(numbers).map(number -> Address.fromExternal(context, number)).toList();
Cursor conversations = threadDatabase.getFilteredConversationList(addresses);
return conversations != null ? new CursorList<>(conversations, new ThreadModelBuilder(threadDatabase))
: CursorList.emptyList();
}
private CursorList<MessageResult> queryMessages(@NonNull String query) {
Cursor messages = searchDatabase.queryMessages(query);
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.
*/
private String sanitizeQuery(@NonNull String query) {
StringBuilder out = new StringBuilder();
for (int i = 0; i < query.length(); i++) {
char c = query.charAt(i);
if (!BANNED_CHARACTERS.contains(c)) {
out.append(c);
}
}
return out.toString();
}
private static class RecipientModelBuilder implements CursorList.ModelBuilder<Recipient> {
private final Context context;
RecipientModelBuilder(@NonNull Context context) {
this.context = context;
}
@Override
public Recipient build(@NonNull Cursor cursor) {
Address address = Address.fromExternal(context, cursor.getString(1));
return Recipient.from(context, address, false);
}
}
private static class ThreadModelBuilder implements CursorList.ModelBuilder<ThreadRecord> {
private final ThreadDatabase threadDatabase;
ThreadModelBuilder(@NonNull ThreadDatabase threadDatabase) {
this.threadDatabase = threadDatabase;
}
@Override
public ThreadRecord build(@NonNull Cursor cursor) {
return threadDatabase.readerFor(cursor).getCurrent();
}
}
private static class MessageModelBuilder implements CursorList.ModelBuilder<MessageResult> {
private final Context context;
MessageModelBuilder(@NonNull Context context) {
this.context = context;
}
@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));
return new MessageResult(recipient, body, threadId, receivedMs);
}
}
public interface Callback {
void onResult(@NonNull SearchResult result);
}
}

View File

@ -0,0 +1,89 @@
package org.thoughtcrime.securesms.search;
import android.arch.lifecycle.LiveData;
import android.arch.lifecycle.MutableLiveData;
import android.arch.lifecycle.ViewModel;
import android.arch.lifecycle.ViewModelProvider;
import android.support.annotation.NonNull;
import org.thoughtcrime.securesms.search.model.SearchResult;
import org.thoughtcrime.securesms.util.Debouncer;
/**
* A {@link ViewModel} for handling all the business logic and interactions that take place inside
* of the {@link SearchFragment}.
*
* This class should be view- and Android-agnostic, and therefore should contain no references to
* things like {@link android.content.Context}, {@link android.view.View},
* {@link android.support.v4.app.Fragment}, etc.
*/
class SearchViewModel extends ViewModel {
private final ClosingLiveData searchResult;
private final SearchRepository searchRepository;
private final Debouncer debouncer;
private String lastQuery;
SearchViewModel(@NonNull SearchRepository searchRepository) {
this.searchResult = new ClosingLiveData();
this.searchRepository = searchRepository;
this.debouncer = new Debouncer(500);
}
LiveData<SearchResult> getSearchResult() {
return searchResult;
}
void updateQuery(String query) {
lastQuery = query;
debouncer.publish(() -> searchRepository.query(query, searchResult::postValue));
}
@NonNull
String getLastQuery() {
return lastQuery == null ? "" : lastQuery;
}
@Override
protected void onCleared() {
searchResult.close();
}
/**
* Ensures that the previous {@link SearchResult} is always closed whenever we set a new one.
*/
private static class ClosingLiveData extends MutableLiveData<SearchResult> {
@Override
public void setValue(SearchResult value) {
SearchResult previous = getValue();
if (previous != null) {
previous.close();
}
super.setValue(value);
}
public void close() {
SearchResult value = getValue();
if (value != null) {
value.close();
}
}
}
public static class Factory extends ViewModelProvider.NewInstanceFactory {
private final SearchRepository searchRepository;
public Factory(@NonNull SearchRepository searchRepository) {
this.searchRepository = searchRepository;
}
@NonNull
@Override
public <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
return modelClass.cast(new SearchViewModel(searchRepository));
}
}
}

View File

@ -0,0 +1,27 @@
package org.thoughtcrime.securesms.search.model;
import android.support.annotation.NonNull;
import org.thoughtcrime.securesms.recipients.Recipient;
/**
* Represents a search result for a message.
*/
public class MessageResult {
public final Recipient recipient;
public final String bodySnippet;
public final long threadId;
public final long receivedTimestampMs;
public MessageResult(@NonNull Recipient recipient,
@NonNull String bodySnippet,
long threadId,
long receivedTimestampMs)
{
this.recipient = recipient;
this.bodySnippet = bodySnippet;
this.threadId = threadId;
this.receivedTimestampMs = receivedTimestampMs;
}
}

View File

@ -0,0 +1,64 @@
package org.thoughtcrime.securesms.search.model;
import android.support.annotation.NonNull;
import org.thoughtcrime.securesms.database.CursorList;
import org.thoughtcrime.securesms.database.model.ThreadRecord;
import org.thoughtcrime.securesms.recipients.Recipient;
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("", CursorList.emptyList(), CursorList.emptyList(), CursorList.emptyList());
private final String query;
private final CursorList<Recipient> contacts;
private final CursorList<ThreadRecord> conversations;
private final CursorList<MessageResult> messages;
public SearchResult(@NonNull String query,
@NonNull CursorList<Recipient> contacts,
@NonNull CursorList<ThreadRecord> conversations,
@NonNull CursorList<MessageResult> messages)
{
this.query = query;
this.contacts = contacts;
this.conversations = conversations;
this.messages = messages;
}
public List<Recipient> getContacts() {
return contacts;
}
public List<ThreadRecord> getConversations() {
return conversations;
}
public List<MessageResult> getMessages() {
return messages;
}
public String getQuery() {
return query;
}
public int size() {
return contacts.size() + conversations.size() + messages.size();
}
public boolean isEmpty() {
return size() == 0;
}
public void close() {
contacts.close();
conversations.close();
messages.close();
}
}

View File

@ -0,0 +1,32 @@
package org.thoughtcrime.securesms.util;
import android.os.Handler;
/**
* A class that will throttle the number of runnables executed to be at most once every specified
* interval.
*
* Useful for performing actions in response to rapid user input, such as inputting text, where you
* don't necessarily want to perform an action after <em>every</em> input.
*
* See http://rxmarbles.com/#debounce
*/
public class Debouncer {
private final Handler handler;
private final long threshold;
/**
* @param threshold Only one runnable will be executed via {@link #publish(Runnable)} every
* {@code threshold} milliseconds.
*/
public Debouncer(long threshold) {
this.handler = new Handler();
this.threshold = threshold;
}
public void publish(Runnable runnable) {
handler.removeCallbacksAndMessages(null);
handler.postDelayed(runnable, threshold);
}
}