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 {
|
maven {
|
||||||
url "https://raw.github.com/signalapp/maven/master/circular-progress-button/releases/"
|
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 {
|
maven {
|
||||||
url "https://maven.google.com"
|
url "https://maven.google.com"
|
||||||
}
|
}
|
||||||
@ -119,7 +125,7 @@ dependencies {
|
|||||||
}
|
}
|
||||||
compile 'com.codewaves.stickyheadergrid:stickyheadergrid:0.9.4'
|
compile 'com.codewaves.stickyheadergrid:stickyheadergrid:0.9.4'
|
||||||
compile 'com.github.dmytrodanylyk.circular-progress-button:library:1.1.3-S2'
|
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') {
|
compile ('com.googlecode.ez-vcard:ez-vcard:0.9.11') {
|
||||||
exclude group: 'com.fasterxml.jackson.core'
|
exclude group: 'com.fasterxml.jackson.core'
|
||||||
exclude group: 'org.freemarker'
|
exclude group: 'org.freemarker'
|
||||||
@ -192,7 +198,8 @@ dependencyVerification {
|
|||||||
'com.annimon:stream:5da6e2e3e0551d61a3ea7014f04312276549e3dd739cf637996e4cf43c5535b9',
|
'com.annimon:stream:5da6e2e3e0551d61a3ea7014f04312276549e3dd739cf637996e4cf43c5535b9',
|
||||||
'com.takisoft.fix:colorpicker:f5d0dbabe406a1800498ca9c1faf34db36e021d8488bf10360f29961fe3ab0d1',
|
'com.takisoft.fix:colorpicker:f5d0dbabe406a1800498ca9c1faf34db36e021d8488bf10360f29961fe3ab0d1',
|
||||||
'com.github.dmytrodanylyk.circular-progress-button:library:8dc6a29a5a8db7b2ad5a9a7fda1dc9ae0893f4c8f0545732b2c63854ea693e8e',
|
'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-iid:54e919f9957b8b7820da7ee9b83471d00d0cac1cf08ddea8b5b41aea80bb1a70',
|
||||||
'com.google.android.gms:play-services-base:0ca636a8fc9a5af45e607cdcd61783bf5d561cbbb0f862021ce69606eee5ad49',
|
'com.google.android.gms:play-services-base:0ca636a8fc9a5af45e607cdcd61783bf5d561cbbb0f862021ce69606eee5ad49',
|
||||||
'com.google.android.gms:play-services-tasks:69ec265168e601d0203d04cd42e34bb019b2f029aa1e16fabd38a5153eea2086',
|
'com.google.android.gms:play-services-tasks:69ec265168e601d0203d04cd42e34bb019b2f029aa1e16fabd38a5153eea2086',
|
||||||
@ -223,6 +230,7 @@ dependencyVerification {
|
|||||||
'javax.inject:javax.inject:91c77044a50c481636c32d916fd89c9118a72195390452c81065080f957de7ff',
|
'javax.inject:javax.inject:91c77044a50c481636c32d916fd89c9118a72195390452c81065080f957de7ff',
|
||||||
'com.klinkerapps:logger:177e325259a8b111ad6745ec10db5861723c99f402222b80629f576f49408541',
|
'com.klinkerapps:logger:177e325259a8b111ad6745ec10db5861723c99f402222b80629f576f49408541',
|
||||||
'com.google.android:flexbox:a9989fd13ae2ee42765dfc515fe362edf4f326e74925d02a10369df8092a4935',
|
'com.google.android:flexbox:a9989fd13ae2ee42765dfc515fe362edf4f326e74925d02a10369df8092a4935',
|
||||||
|
'org.jsoup:jsoup:abeaf34795a4de70f72aed6de5966d2955ec7eb348eeb813324f23c999575473',
|
||||||
'org.whispersystems:curve25519-android:82595394422b957d4a5b5f1b27b75ba25cf6dc4db4d312418ca38cd6fff279ca',
|
'org.whispersystems:curve25519-android:82595394422b957d4a5b5f1b27b75ba25cf6dc4db4d312418ca38cd6fff279ca',
|
||||||
'org.whispersystems:signal-protocol-java:5152c2b01a25147967d6bf82e540f947901bdfa79260be3eb3e96b03f787d6b5',
|
'org.whispersystems:signal-protocol-java:5152c2b01a25147967d6bf82e540f947901bdfa79260be3eb3e96b03f787d6b5',
|
||||||
'com.google.protobuf:protobuf-java:e0c1c64575c005601725e7c6a02cebf9e1285e888f756b2a1d73ffa8d725cc74',
|
'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"?>
|
<?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_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_gravity="center"
|
android:layout_gravity="center"
|
||||||
android:background="?conversation_item_bubble_background"
|
android:gravity="center"
|
||||||
android:textColor="?conversation_item_sent_text_primary_color"
|
android:textColor="?conversation_item_sent_text_primary_color"
|
||||||
android:text="@string/load_more_header__see_full_conversation" />
|
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_add_ringtone_text">Add ringtone</string>
|
||||||
<string name="RingtonePreference_unable_to_add_ringtone">Unable to add custom 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 -->
|
<!-- SharedContactDetailsActivity -->
|
||||||
<string name="SharedContactDetailsActivity_add_to_contacts">Add to Contacts</string>
|
<string name="SharedContactDetailsActivity_add_to_contacts">Add to Contacts</string>
|
||||||
<string name="SharedContactDetailsActivity_invite_to_signal">Invite to Signal</string>
|
<string name="SharedContactDetailsActivity_invite_to_signal">Invite to Signal</string>
|
||||||
@ -980,6 +987,7 @@
|
|||||||
|
|
||||||
<!-- load_more_header -->
|
<!-- load_more_header -->
|
||||||
<string name="load_more_header__see_full_conversation">See full conversation</string>
|
<string name="load_more_header__see_full_conversation">See full conversation</string>
|
||||||
|
<string name="load_more_header__loading">Loading</string>
|
||||||
|
|
||||||
<!-- media_overview_activity -->
|
<!-- media_overview_activity -->
|
||||||
<string name="media_overview_activity__no_media">No media</string>
|
<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 DISTRIBUTION_TYPE_EXTRA = "distribution_type";
|
||||||
public static final String TIMING_EXTRA = "timing";
|
public static final String TIMING_EXTRA = "timing";
|
||||||
public static final String LAST_SEEN_EXTRA = "last_seen";
|
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_GALLERY = 1;
|
||||||
private static final int PICK_DOCUMENT = 2;
|
private static final int PICK_DOCUMENT = 2;
|
||||||
|
@ -51,6 +51,7 @@ import android.view.animation.Animation;
|
|||||||
import android.view.animation.AnimationUtils;
|
import android.view.animation.AnimationUtils;
|
||||||
import android.widget.TextView;
|
import android.widget.TextView;
|
||||||
import android.widget.Toast;
|
import android.widget.Toast;
|
||||||
|
import android.widget.ViewSwitcher;
|
||||||
|
|
||||||
import org.thoughtcrime.securesms.ConversationAdapter.HeaderViewHolder;
|
import org.thoughtcrime.securesms.ConversationAdapter.HeaderViewHolder;
|
||||||
import org.thoughtcrime.securesms.ConversationAdapter.ItemClickListener;
|
import org.thoughtcrime.securesms.ConversationAdapter.ItemClickListener;
|
||||||
@ -90,10 +91,12 @@ import java.util.Set;
|
|||||||
public class ConversationFragment extends Fragment
|
public class ConversationFragment extends Fragment
|
||||||
implements LoaderManager.LoaderCallbacks<Cursor>
|
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 PARTIAL_CONVERSATION_LIMIT = 500;
|
||||||
private static final int CODE_ADD_EDIT_CONTACT = 77;
|
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 ActionModeCallback actionModeCallback = new ActionModeCallback();
|
||||||
private final ItemClickListener selectionClickListener = new ConversationFragmentItemClickListener();
|
private final ItemClickListener selectionClickListener = new ConversationFragmentItemClickListener();
|
||||||
@ -103,12 +106,16 @@ public class ConversationFragment extends Fragment
|
|||||||
private Recipient recipient;
|
private Recipient recipient;
|
||||||
private long threadId;
|
private long threadId;
|
||||||
private long lastSeen;
|
private long lastSeen;
|
||||||
|
private int startingPosition;
|
||||||
|
private int previousOffset;
|
||||||
private boolean firstLoad;
|
private boolean firstLoad;
|
||||||
|
private long loaderStartTime;
|
||||||
private ActionMode actionMode;
|
private ActionMode actionMode;
|
||||||
private Locale locale;
|
private Locale locale;
|
||||||
private RecyclerView list;
|
private RecyclerView list;
|
||||||
private RecyclerView.ItemDecoration lastSeenDecoration;
|
private RecyclerView.ItemDecoration lastSeenDecoration;
|
||||||
private View loadMoreView;
|
private ViewSwitcher topLoadMoreView;
|
||||||
|
private ViewSwitcher bottomLoadMoreView;
|
||||||
private UnknownSenderView unknownSenderView;
|
private UnknownSenderView unknownSenderView;
|
||||||
private View composeDivider;
|
private View composeDivider;
|
||||||
private View scrollToBottomButton;
|
private View scrollToBottomButton;
|
||||||
@ -135,12 +142,10 @@ public class ConversationFragment extends Fragment
|
|||||||
list.setLayoutManager(layoutManager);
|
list.setLayoutManager(layoutManager);
|
||||||
list.setItemAnimator(null);
|
list.setItemAnimator(null);
|
||||||
|
|
||||||
loadMoreView = inflater.inflate(R.layout.load_more_header, container, false);
|
topLoadMoreView = (ViewSwitcher) inflater.inflate(R.layout.load_more_header, container, false);
|
||||||
loadMoreView.setOnClickListener(v -> {
|
bottomLoadMoreView = (ViewSwitcher) inflater.inflate(R.layout.load_more_header, container, false);
|
||||||
Bundle args = new Bundle();
|
initializeLoadMoreView(topLoadMoreView);
|
||||||
args.putLong("limit", 0);
|
initializeLoadMoreView(bottomLoadMoreView);
|
||||||
getLoaderManager().restartLoader(0, args, ConversationFragment.this);
|
|
||||||
});
|
|
||||||
|
|
||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
@ -189,6 +194,7 @@ public class ConversationFragment extends Fragment
|
|||||||
this.recipient = Recipient.from(getActivity(), getActivity().getIntent().getParcelableExtra(ConversationActivity.ADDRESS_EXTRA), true);
|
this.recipient = Recipient.from(getActivity(), getActivity().getIntent().getParcelableExtra(ConversationActivity.ADDRESS_EXTRA), true);
|
||||||
this.threadId = this.getActivity().getIntent().getLongExtra(ConversationActivity.THREAD_ID_EXTRA, -1);
|
this.threadId = this.getActivity().getIntent().getLongExtra(ConversationActivity.THREAD_ID_EXTRA, -1);
|
||||||
this.lastSeen = this.getActivity().getIntent().getLongExtra(ConversationActivity.LAST_SEEN_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.firstLoad = true;
|
||||||
this.unknownSenderView = new UnknownSenderView(getActivity(), recipient, threadId);
|
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) {
|
private void setCorrectMenuVisibility(Menu menu) {
|
||||||
Set<MessageRecord> messageRecords = getListAdapter().getSelectedItems();
|
Set<MessageRecord> messageRecords = getListAdapter().getSelectedItems();
|
||||||
boolean actionMessage = false;
|
boolean actionMessage = false;
|
||||||
@ -278,7 +294,11 @@ public class ConversationFragment extends Fragment
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void scrollToBottom() {
|
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) {
|
public void setLastSeen(long lastSeen) {
|
||||||
@ -424,43 +444,76 @@ public class ConversationFragment extends Fragment
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Loader<Cursor> onCreateLoader(int id, Bundle args) {
|
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
|
@Override
|
||||||
public void onLoadFinished(Loader<Cursor> cursorLoader, Cursor cursor) {
|
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;
|
ConversationLoader loader = (ConversationLoader)cursorLoader;
|
||||||
|
|
||||||
if (list.getAdapter() != null) {
|
ConversationAdapter adapter = getListAdapter();
|
||||||
if (cursor.getCount() >= PARTIAL_CONVERSATION_LIMIT && loader.hasLimit()) {
|
if (adapter == null) {
|
||||||
getListAdapter().setFooterView(loadMoreView);
|
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 {
|
} 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);
|
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) {
|
View firstView = list.getLayoutManager().getChildAt(scrollPosition);
|
||||||
setLastSeen(0);
|
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) {
|
private void scrollToLastSeenPosition(final int lastSeenPosition) {
|
||||||
if (lastSeenPosition > 0) {
|
if (lastSeenPosition > 0) {
|
||||||
list.post(() -> ((LinearLayoutManager)list.getLayoutManager()).scrollToPositionWithOffset(lastSeenPosition, list.getHeight()));
|
list.post(() -> ((LinearLayoutManager)list.getLayoutManager()).scrollToPositionWithOffset(lastSeenPosition, list.getHeight()));
|
||||||
|
@ -25,9 +25,12 @@ import android.net.Uri;
|
|||||||
import android.os.AsyncTask;
|
import android.os.AsyncTask;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.support.v7.widget.Toolbar;
|
import android.support.v7.widget.Toolbar;
|
||||||
|
import android.text.TextUtils;
|
||||||
|
import android.util.Log;
|
||||||
import android.view.Menu;
|
import android.view.Menu;
|
||||||
import android.view.MenuInflater;
|
import android.view.MenuInflater;
|
||||||
import android.view.MenuItem;
|
import android.view.MenuItem;
|
||||||
|
import android.view.ViewGroup;
|
||||||
import android.widget.ImageView;
|
import android.widget.ImageView;
|
||||||
import android.widget.Toast;
|
import android.widget.Toast;
|
||||||
|
|
||||||
@ -40,6 +43,7 @@ import org.thoughtcrime.securesms.notifications.MarkReadReceiver;
|
|||||||
import org.thoughtcrime.securesms.notifications.MessageNotifier;
|
import org.thoughtcrime.securesms.notifications.MessageNotifier;
|
||||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||||
|
import org.thoughtcrime.securesms.search.SearchFragment;
|
||||||
import org.thoughtcrime.securesms.service.KeyCachingService;
|
import org.thoughtcrime.securesms.service.KeyCachingService;
|
||||||
import org.thoughtcrime.securesms.util.DynamicLanguage;
|
import org.thoughtcrime.securesms.util.DynamicLanguage;
|
||||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
|
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
|
||||||
@ -57,9 +61,11 @@ public class ConversationListActivity extends PassphraseRequiredActionBarActivit
|
|||||||
private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme();
|
private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme();
|
||||||
private final DynamicLanguage dynamicLanguage = new DynamicLanguage();
|
private final DynamicLanguage dynamicLanguage = new DynamicLanguage();
|
||||||
|
|
||||||
private ConversationListFragment fragment;
|
private ConversationListFragment conversationListFragment;
|
||||||
|
private SearchFragment searchFragment;
|
||||||
private SearchToolbar searchToolbar;
|
private SearchToolbar searchToolbar;
|
||||||
private ImageView searchAction;
|
private ImageView searchAction;
|
||||||
|
private ViewGroup fragmentContainer;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onPreCreate() {
|
protected void onPreCreate() {
|
||||||
@ -74,9 +80,10 @@ public class ConversationListActivity extends PassphraseRequiredActionBarActivit
|
|||||||
Toolbar toolbar = findViewById(R.id.toolbar);
|
Toolbar toolbar = findViewById(R.id.toolbar);
|
||||||
setSupportActionBar(toolbar);
|
setSupportActionBar(toolbar);
|
||||||
|
|
||||||
searchToolbar = findViewById(R.id.search_toolbar);
|
searchToolbar = findViewById(R.id.search_toolbar);
|
||||||
searchAction = findViewById(R.id.search_action);
|
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();
|
initializeSearchListener();
|
||||||
|
|
||||||
@ -123,15 +130,31 @@ public class ConversationListActivity extends PassphraseRequiredActionBarActivit
|
|||||||
searchToolbar.setListener(new SearchToolbar.SearchListener() {
|
searchToolbar.setListener(new SearchToolbar.SearchListener() {
|
||||||
@Override
|
@Override
|
||||||
public void onSearchTextChange(String text) {
|
public void onSearchTextChange(String text) {
|
||||||
if (fragment != null) {
|
String trimmed = text.trim();
|
||||||
fragment.setQueryFilter(text);
|
|
||||||
|
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
|
@Override
|
||||||
public void onSearchReset() {
|
public void onSearchClosed() {
|
||||||
if (fragment != null) {
|
if (searchFragment != null) {
|
||||||
fragment.resetQueryFilter();
|
getSupportFragmentManager().beginTransaction()
|
||||||
|
.remove(searchFragment)
|
||||||
|
.commit();
|
||||||
|
searchFragment = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -156,12 +179,19 @@ public class ConversationListActivity extends PassphraseRequiredActionBarActivit
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onCreateConversation(long threadId, Recipient recipient, int distributionType, long lastSeen) {
|
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 intent = new Intent(this, ConversationActivity.class);
|
||||||
intent.putExtra(ConversationActivity.ADDRESS_EXTRA, recipient.getAddress());
|
intent.putExtra(ConversationActivity.ADDRESS_EXTRA, recipient.getAddress());
|
||||||
intent.putExtra(ConversationActivity.THREAD_ID_EXTRA, threadId);
|
intent.putExtra(ConversationActivity.THREAD_ID_EXTRA, threadId);
|
||||||
intent.putExtra(ConversationActivity.DISTRIBUTION_TYPE_EXTRA, distributionType);
|
intent.putExtra(ConversationActivity.DISTRIBUTION_TYPE_EXTRA, distributionType);
|
||||||
intent.putExtra(ConversationActivity.TIMING_EXTRA, System.currentTimeMillis());
|
intent.putExtra(ConversationActivity.TIMING_EXTRA, System.currentTimeMillis());
|
||||||
intent.putExtra(ConversationActivity.LAST_SEEN_EXTRA, lastSeen);
|
intent.putExtra(ConversationActivity.LAST_SEEN_EXTRA, lastSeen);
|
||||||
|
intent.putExtra(ConversationActivity.STARTING_POSITION_EXTRA, startingPosition);
|
||||||
|
|
||||||
startActivity(intent);
|
startActivity(intent);
|
||||||
overridePendingTransition(R.anim.slide_from_right, R.anim.fade_scale_out);
|
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;
|
||||||
import android.os.Build.VERSION_CODES;
|
import android.os.Build.VERSION_CODES;
|
||||||
import android.support.annotation.NonNull;
|
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.util.AttributeSet;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.widget.ImageView;
|
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.mms.GlideRequests;
|
||||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||||
import org.thoughtcrime.securesms.recipients.RecipientModifiedListener;
|
import org.thoughtcrime.securesms.recipients.RecipientModifiedListener;
|
||||||
|
import org.thoughtcrime.securesms.search.model.MessageResult;
|
||||||
import org.thoughtcrime.securesms.util.DateUtils;
|
import org.thoughtcrime.securesms.util.DateUtils;
|
||||||
import org.thoughtcrime.securesms.util.Util;
|
import org.thoughtcrime.securesms.util.Util;
|
||||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
@ -57,8 +64,9 @@ public class ConversationListItem extends RelativeLayout
|
|||||||
@SuppressWarnings("unused")
|
@SuppressWarnings("unused")
|
||||||
private final static String TAG = ConversationListItem.class.getSimpleName();
|
private final static String TAG = ConversationListItem.class.getSimpleName();
|
||||||
|
|
||||||
private final static Typeface BOLD_TYPEFACE = Typeface.create("sans-serif", Typeface.BOLD);
|
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 LIGHT_TYPEFACE = Typeface.create("sans-serif-light", Typeface.NORMAL);
|
||||||
|
private final static StyleSpan BOLD_SPAN = new StyleSpan(Typeface.BOLD);
|
||||||
|
|
||||||
private Set<Long> selectedThreads;
|
private Set<Long> selectedThreads;
|
||||||
private Recipient recipient;
|
private Recipient recipient;
|
||||||
@ -107,8 +115,20 @@ public class ConversationListItem extends RelativeLayout
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void bind(@NonNull ThreadRecord thread,
|
public void bind(@NonNull ThreadRecord thread,
|
||||||
@NonNull GlideRequests glideRequests, @NonNull Locale locale,
|
@NonNull GlideRequests glideRequests,
|
||||||
@NonNull Set<Long> selectedThreads, boolean batchMode)
|
@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.selectedThreads = selectedThreads;
|
||||||
this.recipient = thread.getRecipient();
|
this.recipient = thread.getRecipient();
|
||||||
@ -119,7 +139,11 @@ public class ConversationListItem extends RelativeLayout
|
|||||||
this.lastSeen = thread.getLastSeen();
|
this.lastSeen = thread.getLastSeen();
|
||||||
|
|
||||||
this.recipient.addListener(this);
|
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.setText(thread.getDisplayBody());
|
||||||
// this.subjectView.setTypeface(read ? LIGHT_TYPEFACE : BOLD_TYPEFACE);
|
// this.subjectView.setTypeface(read ? LIGHT_TYPEFACE : BOLD_TYPEFACE);
|
||||||
@ -144,6 +168,56 @@ public class ConversationListItem extends RelativeLayout
|
|||||||
this.contactPhotoImage.setAvatar(glideRequests, recipient, true);
|
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
|
@Override
|
||||||
public void unbind() {
|
public void unbind() {
|
||||||
if (this.recipient != null) this.recipient.removeListener(this);
|
if (this.recipient != null) this.recipient.removeListener(this);
|
||||||
@ -241,6 +315,26 @@ public class ConversationListItem extends RelativeLayout
|
|||||||
unreadIndicator.setVisibility(View.VISIBLE);
|
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
|
@Override
|
||||||
public void onModified(final Recipient recipient) {
|
public void onModified(final Recipient recipient) {
|
||||||
Util.runOnMain(() -> {
|
Util.runOnMain(() -> {
|
||||||
|
@ -81,6 +81,7 @@ public class DatabaseUpgradeActivity extends BaseActivity {
|
|||||||
public static final int SQLCIPHER_COMPLETE = 352;
|
public static final int SQLCIPHER_COMPLETE = 352;
|
||||||
public static final int REMOVE_JOURNAL = 353;
|
public static final int REMOVE_JOURNAL = 353;
|
||||||
public static final int REMOVE_CACHE = 354;
|
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>() {{
|
private static final SortedSet<Integer> UPGRADE_VERSIONS = new TreeSet<Integer>() {{
|
||||||
add(NO_MORE_KEY_EXCHANGE_PREFIX_VERSION);
|
add(NO_MORE_KEY_EXCHANGE_PREFIX_VERSION);
|
||||||
@ -101,6 +102,7 @@ public class DatabaseUpgradeActivity extends BaseActivity {
|
|||||||
add(SQLCIPHER);
|
add(SQLCIPHER);
|
||||||
add(SQLCIPHER_COMPLETE);
|
add(SQLCIPHER_COMPLETE);
|
||||||
add(REMOVE_CACHE);
|
add(REMOVE_CACHE);
|
||||||
|
add(FULL_TEXT_SEARCH);
|
||||||
}};
|
}};
|
||||||
|
|
||||||
private MasterSecret masterSecret;
|
private MasterSecret masterSecret;
|
||||||
|
@ -175,7 +175,7 @@ public class ShareActivity extends PassphraseRequiredActionBarActivity
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onSearchReset() {
|
public void onSearchClosed() {
|
||||||
if (contactsFragment != null) {
|
if (contactsFragment != null) {
|
||||||
contactsFragment.resetQueryFilter();
|
contactsFragment.resetQueryFilter();
|
||||||
}
|
}
|
||||||
|
@ -120,7 +120,8 @@ public class SearchToolbar extends LinearLayout {
|
|||||||
private void hide() {
|
private void hide() {
|
||||||
if (getVisibility() == View.VISIBLE) {
|
if (getVisibility() == View.VISIBLE) {
|
||||||
|
|
||||||
if (listener != null) listener.onSearchReset();
|
|
||||||
|
if (listener != null) listener.onSearchClosed();
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= 21) {
|
if (Build.VERSION.SDK_INT >= 21) {
|
||||||
Animator animator = ViewAnimationUtils.createCircularReveal(this, (int)x, (int)y, getWidth(), 0);
|
Animator animator = ViewAnimationUtils.createCircularReveal(this, (int)x, (int)y, getWidth(), 0);
|
||||||
@ -149,7 +150,7 @@ public class SearchToolbar extends LinearLayout {
|
|||||||
|
|
||||||
public interface SearchListener {
|
public interface SearchListener {
|
||||||
void onSearchTextChange(String text);
|
void onSearchTextChange(String text);
|
||||||
void onSearchReset();
|
void onSearchClosed();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -145,7 +145,7 @@ public class ContactsDatabase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("Recycle")
|
@SuppressLint("Recycle")
|
||||||
@NonNull Cursor querySystemContacts(@Nullable String filter) {
|
public @NonNull Cursor querySystemContacts(@Nullable String filter) {
|
||||||
Uri uri;
|
Uri uri;
|
||||||
|
|
||||||
if (!TextUtils.isEmpty(filter)) {
|
if (!TextUtils.isEmpty(filter)) {
|
||||||
@ -193,7 +193,7 @@ public class ContactsDatabase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("Recycle")
|
@SuppressLint("Recycle")
|
||||||
@NonNull Cursor queryTextSecureContacts(String filter) {
|
public @NonNull Cursor queryTextSecureContacts(String filter) {
|
||||||
String[] projection = new String[] {ContactsContract.Contacts.DISPLAY_NAME,
|
String[] projection = new String[] {ContactsContract.Contacts.DISPLAY_NAME,
|
||||||
ContactsContract.Data.DATA1};
|
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 OneTimePreKeyDatabase preKeyDatabase;
|
||||||
private final SignedPreKeyDatabase signedPreKeyDatabase;
|
private final SignedPreKeyDatabase signedPreKeyDatabase;
|
||||||
private final SessionDatabase sessionDatabase;
|
private final SessionDatabase sessionDatabase;
|
||||||
|
private final SearchDatabase searchDatabase;
|
||||||
|
|
||||||
public static DatabaseFactory getInstance(Context context) {
|
public static DatabaseFactory getInstance(Context context) {
|
||||||
synchronized (lock) {
|
synchronized (lock) {
|
||||||
@ -130,6 +131,10 @@ public class DatabaseFactory {
|
|||||||
return getInstance(context).sessionDatabase;
|
return getInstance(context).sessionDatabase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static SearchDatabase getSearchDatabase(Context context) {
|
||||||
|
return getInstance(context).searchDatabase;
|
||||||
|
}
|
||||||
|
|
||||||
public static SQLiteDatabase getBackupDatabase(Context context) {
|
public static SQLiteDatabase getBackupDatabase(Context context) {
|
||||||
return getInstance(context).databaseHelper.getReadableDatabase();
|
return getInstance(context).databaseHelper.getReadableDatabase();
|
||||||
}
|
}
|
||||||
@ -162,6 +167,7 @@ public class DatabaseFactory {
|
|||||||
this.preKeyDatabase = new OneTimePreKeyDatabase(context, databaseHelper);
|
this.preKeyDatabase = new OneTimePreKeyDatabase(context, databaseHelper);
|
||||||
this.signedPreKeyDatabase = new SignedPreKeyDatabase(context, databaseHelper);
|
this.signedPreKeyDatabase = new SignedPreKeyDatabase(context, databaseHelper);
|
||||||
this.sessionDatabase = new SessionDatabase(context, databaseHelper);
|
this.sessionDatabase = new SessionDatabase(context, databaseHelper);
|
||||||
|
this.searchDatabase = new SearchDatabase(context, databaseHelper);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void onApplicationLevelUpgrade(@NonNull Context context, @NonNull MasterSecret masterSecret,
|
public void onApplicationLevelUpgrade(@NonNull Context context, @NonNull MasterSecret masterSecret,
|
||||||
|
@ -93,18 +93,19 @@ public class MmsSmsDatabase extends Database {
|
|||||||
return null;
|
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 order = MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " DESC";
|
||||||
String selection = MmsSmsColumns.THREAD_ID + " = " + threadId;
|
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);
|
setNotifyConverationListeners(cursor, threadId);
|
||||||
|
|
||||||
return cursor;
|
return cursor;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Cursor getConversation(long threadId) {
|
public Cursor getConversation(long threadId) {
|
||||||
return getConversation(threadId, 0);
|
return getConversation(threadId, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Cursor getIdentityConflictMessagesForThread(long threadId) {
|
public Cursor getIdentityConflictMessagesForThread(long threadId) {
|
||||||
@ -179,6 +180,26 @@ public class MmsSmsDatabase extends Database {
|
|||||||
return -1;
|
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) {
|
private Cursor queryTables(String[] projection, String selection, String order, String limit) {
|
||||||
String[] mmsProjection = {MmsDatabase.DATE_SENT + " AS " + MmsSmsColumns.NORMALIZED_DATE_SENT,
|
String[] mmsProjection = {MmsDatabase.DATE_SENT + " AS " + MmsSmsColumns.NORMALIZED_DATE_SENT,
|
||||||
MmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED,
|
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.Pair;
|
||||||
import org.whispersystems.libsignal.util.guava.Optional;
|
import org.whispersystems.libsignal.util.guava.Optional;
|
||||||
|
|
||||||
|
import java.io.Closeable;
|
||||||
import java.util.LinkedList;
|
import java.util.LinkedList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
@ -625,7 +626,7 @@ public class ThreadDatabase extends Database {
|
|||||||
public static final int INBOX_ZERO = 4;
|
public static final int INBOX_ZERO = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
public class Reader {
|
public class Reader implements Closeable {
|
||||||
|
|
||||||
private final Cursor cursor;
|
private final Cursor cursor;
|
||||||
|
|
||||||
@ -692,8 +693,11 @@ public class ThreadDatabase extends Database {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
public void close() {
|
public void close() {
|
||||||
cursor.close();
|
if (cursor != null) {
|
||||||
|
cursor.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,6 +4,7 @@ package org.thoughtcrime.securesms.database.helpers;
|
|||||||
import android.content.ContentValues;
|
import android.content.ContentValues;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.database.Cursor;
|
import android.database.Cursor;
|
||||||
|
import android.os.SystemClock;
|
||||||
import android.support.annotation.NonNull;
|
import android.support.annotation.NonNull;
|
||||||
import android.util.Log;
|
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.OneTimePreKeyDatabase;
|
||||||
import org.thoughtcrime.securesms.database.PushDatabase;
|
import org.thoughtcrime.securesms.database.PushDatabase;
|
||||||
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
||||||
|
import org.thoughtcrime.securesms.database.SearchDatabase;
|
||||||
import org.thoughtcrime.securesms.database.SessionDatabase;
|
import org.thoughtcrime.securesms.database.SessionDatabase;
|
||||||
import org.thoughtcrime.securesms.database.SignedPreKeyDatabase;
|
import org.thoughtcrime.securesms.database.SignedPreKeyDatabase;
|
||||||
import org.thoughtcrime.securesms.database.SmsDatabase;
|
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 ATTACHMENT_DIMENSIONS = 6;
|
||||||
private static final int QUOTED_REPLIES = 7;
|
private static final int QUOTED_REPLIES = 7;
|
||||||
private static final int SHARED_CONTACTS = 8;
|
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 static final String DATABASE_NAME = "signal.db";
|
||||||
|
|
||||||
private final Context context;
|
private final Context context;
|
||||||
@ -86,6 +89,9 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
|
|||||||
db.execSQL(OneTimePreKeyDatabase.CREATE_TABLE);
|
db.execSQL(OneTimePreKeyDatabase.CREATE_TABLE);
|
||||||
db.execSQL(SignedPreKeyDatabase.CREATE_TABLE);
|
db.execSQL(SignedPreKeyDatabase.CREATE_TABLE);
|
||||||
db.execSQL(SessionDatabase.CREATE_TABLE);
|
db.execSQL(SessionDatabase.CREATE_TABLE);
|
||||||
|
for (String sql : SearchDatabase.CREATE_TABLE) {
|
||||||
|
db.execSQL(sql);
|
||||||
|
}
|
||||||
|
|
||||||
executeStatements(db, SmsDatabase.CREATE_INDEXS);
|
executeStatements(db, SmsDatabase.CREATE_INDEXS);
|
||||||
executeStatements(db, MmsDatabase.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");
|
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();
|
db.setTransactionSuccessful();
|
||||||
} finally {
|
} finally {
|
||||||
db.endTransaction();
|
db.endTransaction();
|
||||||
|
@ -9,13 +9,15 @@ import org.whispersystems.libsignal.util.Pair;
|
|||||||
|
|
||||||
public class ConversationLoader extends AbstractCursorLoader {
|
public class ConversationLoader extends AbstractCursorLoader {
|
||||||
private final long threadId;
|
private final long threadId;
|
||||||
private long limit;
|
private int offset;
|
||||||
|
private int limit;
|
||||||
private long lastSeen;
|
private long lastSeen;
|
||||||
private boolean hasSent;
|
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);
|
super(context);
|
||||||
this.threadId = threadId;
|
this.threadId = threadId;
|
||||||
|
this.offset = offset;
|
||||||
this.limit = limit;
|
this.limit = limit;
|
||||||
this.lastSeen = lastSeen;
|
this.lastSeen = lastSeen;
|
||||||
this.hasSent = true;
|
this.hasSent = true;
|
||||||
@ -25,6 +27,14 @@ public class ConversationLoader extends AbstractCursorLoader {
|
|||||||
return limit > 0;
|
return limit > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean hasOffset() {
|
||||||
|
return offset > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getOffset() {
|
||||||
|
return offset;
|
||||||
|
}
|
||||||
|
|
||||||
public long getLastSeen() {
|
public long getLastSeen() {
|
||||||
return lastSeen;
|
return lastSeen;
|
||||||
}
|
}
|
||||||
@ -43,6 +53,6 @@ public class ConversationLoader extends AbstractCursorLoader {
|
|||||||
this.lastSeen = lastSeenAndHasSent.first();
|
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