mirror of
https://github.com/oxen-io/session-android.git
synced 2024-12-22 16:07:30 +00:00
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:
parent
c0b75c2ef5
commit
0449647cf9
12
build.gradle
12
build.gradle
@ -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',
|
||||
|
13
res/drawable/header_search_background.xml
Normal file
13
res/drawable/header_search_background.xml
Normal 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>
|
20
res/layout/fragment_search.xml
Normal file
20
res/layout/fragment_search.xml
Normal 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>
|
13
res/layout/header_search_result.xml
Normal file
13
res/layout/header_search_result.xml
Normal 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>
|
@ -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>
|
@ -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>
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
@ -91,8 +92,10 @@ public class ConversationFragment extends Fragment
|
||||
implements LoaderManager.LoaderCallbacks<Cursor>
|
||||
{
|
||||
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 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();
|
||||
@ -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() {
|
||||
if (((LinearLayoutManager) list.getLayoutManager()).findFirstVisibleItemPosition() < SCROLL_ANIMATION_THRESHOLD) {
|
||||
list.smoothScrollToPosition(0);
|
||||
} else {
|
||||
list.scrollToPosition(0);
|
||||
}
|
||||
}
|
||||
|
||||
public void setLastSeen(long lastSeen) {
|
||||
@ -424,19 +444,35 @@ 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) {
|
||||
ConversationAdapter adapter = getListAdapter();
|
||||
if (adapter == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (cursor.getCount() >= PARTIAL_CONVERSATION_LIMIT && loader.hasLimit()) {
|
||||
getListAdapter().setFooterView(loadMoreView);
|
||||
adapter.setFooterView(topLoadMoreView);
|
||||
} else {
|
||||
getListAdapter().setFooterView(null);
|
||||
adapter.setFooterView(null);
|
||||
}
|
||||
|
||||
if (lastSeen == -1) {
|
||||
@ -444,25 +480,42 @@ public class ConversationFragment extends Fragment
|
||||
}
|
||||
|
||||
if (!loader.hasSent() && !recipient.isSystemContact() && !recipient.isGroupRecipient() && recipient.getRegistered() == RecipientDatabase.RegisteredState.REGISTERED) {
|
||||
getListAdapter().setHeaderView(unknownSenderView);
|
||||
adapter.setHeaderView(unknownSenderView);
|
||||
} else {
|
||||
getListAdapter().setHeaderView(null);
|
||||
adapter.setHeaderView(null);
|
||||
}
|
||||
|
||||
getListAdapter().changeCursor(cursor);
|
||||
if (loader.hasOffset()) {
|
||||
adapter.setHeaderView(bottomLoadMoreView);
|
||||
previousOffset = loader.getOffset();
|
||||
}
|
||||
|
||||
int lastSeenPosition = getListAdapter().findLastSeenPosition(lastSeen);
|
||||
adapter.changeCursor(cursor);
|
||||
|
||||
int lastSeenPosition = adapter.findLastSeenPosition(lastSeen);
|
||||
|
||||
if (firstLoad) {
|
||||
if (startingPosition >= 0) {
|
||||
scrollToStartingPosition(startingPosition);
|
||||
} else {
|
||||
scrollToLastSeenPosition(lastSeenPosition);
|
||||
}
|
||||
firstLoad = false;
|
||||
} else if (previousOffset > 0) {
|
||||
int scrollPosition = previousOffset + ((LinearLayoutManager) list.getLayoutManager()).findFirstVisibleItemPosition();
|
||||
scrollPosition = Math.min(scrollPosition, count - 1);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoaderReset(Loader<Cursor> arg0) {
|
||||
@ -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()));
|
||||
|
@ -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() {
|
||||
@ -76,7 +82,8 @@ public class ConversationListActivity extends PassphraseRequiredActionBarActivit
|
||||
|
||||
searchToolbar = findViewById(R.id.search_toolbar);
|
||||
searchAction = findViewById(R.id.search_action);
|
||||
fragment = initFragment(R.id.fragment_container, new ConversationListFragment(), dynamicLanguage.getCurrentLocale());
|
||||
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);
|
||||
|
@ -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;
|
||||
|
||||
@ -59,6 +66,7 @@ public class ConversationListItem extends RelativeLayout
|
||||
|
||||
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);
|
||||
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(() -> {
|
||||
|
@ -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;
|
||||
|
@ -175,7 +175,7 @@ public class ShareActivity extends PassphraseRequiredActionBarActivity
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSearchReset() {
|
||||
public void onSearchClosed() {
|
||||
if (contactsFragment != null) {
|
||||
contactsFragment.resetQueryFilter();
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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};
|
||||
|
||||
|
191
src/org/thoughtcrime/securesms/database/CursorList.java
Normal file
191
src/org/thoughtcrime/securesms/database/CursorList.java
Normal 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);
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
89
src/org/thoughtcrime/securesms/database/SearchDatabase.java
Normal file
89
src/org/thoughtcrime/securesms/database/SearchDatabase.java
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
@ -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() {
|
||||
if (cursor != null) {
|
||||
cursor.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
178
src/org/thoughtcrime/securesms/search/SearchFragment.java
Normal file
178
src/org/thoughtcrime/securesms/search/SearchFragment.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
219
src/org/thoughtcrime/securesms/search/SearchListAdapter.java
Normal file
219
src/org/thoughtcrime/securesms/search/SearchListAdapter.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
188
src/org/thoughtcrime/securesms/search/SearchRepository.java
Normal file
188
src/org/thoughtcrime/securesms/search/SearchRepository.java
Normal 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);
|
||||
}
|
||||
}
|
89
src/org/thoughtcrime/securesms/search/SearchViewModel.java
Normal file
89
src/org/thoughtcrime/securesms/search/SearchViewModel.java
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
32
src/org/thoughtcrime/securesms/util/Debouncer.java
Normal file
32
src/org/thoughtcrime/securesms/util/Debouncer.java
Normal 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);
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user