mirror of
https://github.com/oxen-io/session-android.git
synced 2025-01-11 18:43:49 +00:00
Implemented conversation search.
You can now search for messages within a specific conversation.
This commit is contained in:
parent
10631d7e71
commit
9f04c28bfd
@ -54,7 +54,15 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"/>
|
||||
|
||||
<include layout="@layout/conversation_input_panel"/>
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<include layout="@layout/conversation_input_panel"/>
|
||||
|
||||
<include layout="@layout/conversation_search_nav" />
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
<Button android:id="@+id/register_button"
|
||||
android:layout_width="fill_parent"
|
||||
|
76
res/layout/conversation_search_nav.xml
Normal file
76
res/layout/conversation_search_nav.xml
Normal file
@ -0,0 +1,76 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<org.thoughtcrime.securesms.components.ConversationSearchBottomBar
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/conversation_search_nav"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?conversation_background"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible"
|
||||
tools:parentTag="android.support.constraint.ConstraintLayout">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/conversation_search_position"
|
||||
style="@style/Signal.Text.Body"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_marginRight="16dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@+id/conversation_search_up"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:text="37 of 73" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/conversation_search_up"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_marginRight="8dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:background="?selectableItemBackgroundBorderless"
|
||||
android:padding="8dp"
|
||||
android:src="@drawable/ic_keyboard_arrow_up_white_36dp"
|
||||
android:tint="@color/signal_primary"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@+id/conversation_search_down"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/conversation_search_down"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_marginRight="8dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:background="?selectableItemBackgroundBorderless"
|
||||
android:padding="8dp"
|
||||
android:src="@drawable/ic_keyboard_arrow_down_white_24dp"
|
||||
android:tint="@color/signal_primary"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<com.pnikosis.materialishprogress.ProgressWheel
|
||||
android:id="@+id/conversation_search_progress_wheel"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginTop="0dp"
|
||||
android:layout_marginBottom="0dp"
|
||||
android:indeterminate="true"
|
||||
android:padding="8dp"
|
||||
android:background="?conversation_background"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:matProg_barColor="@color/core_grey_25"
|
||||
app:matProg_progressIndeterminate="true" />
|
||||
|
||||
</org.thoughtcrime.securesms.components.ConversationSearchBottomBar>
|
@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<item android:title="@string/conversation__menu_view_all_media"
|
||||
android:id="@+id/menu_view_media" />
|
||||
@ -7,6 +8,12 @@
|
||||
<item android:title="@string/conversation__menu_conversation_settings"
|
||||
android:id="@+id/menu_conversation_settings"/>
|
||||
|
||||
|
||||
<item android:title="@string/SearchToolbar_search"
|
||||
android:id="@+id/menu_search"
|
||||
app:actionViewClass="android.support.v7.widget.SearchView"
|
||||
app:showAsAction="collapseActionView"/>
|
||||
|
||||
<item android:title="@string/conversation__menu_add_shortcut"
|
||||
android:id="@+id/menu_add_shortcut"/>
|
||||
|
||||
|
@ -180,6 +180,8 @@
|
||||
<string name="ConversationActivity_signal_cannot_sent_sms_mms_messages_because_it_is_not_your_default_sms_app">Signal cannot send SMS/MMS messages because it is not your default SMS app. Would you like to change this in your Android settings?</string>
|
||||
<string name="ConversationActivity_yes">Yes</string>
|
||||
<string name="ConversationActivity_no">No</string>
|
||||
<string name="ConversationActivity_search_position">%1$d of %2$d</string>
|
||||
<string name="ConversationActivity_no_results">No results</string>
|
||||
|
||||
<!-- ConversationAdapter -->
|
||||
<plurals name="ConversationAdapter_n_unread_messages">
|
||||
|
@ -24,6 +24,7 @@ public interface BindableConversationItem extends Unbindable {
|
||||
@NonNull Locale locale,
|
||||
@NonNull Set<MessageRecord> batchSelected,
|
||||
@NonNull Recipient recipients,
|
||||
@Nullable String searchQuery,
|
||||
boolean pulseHighlight);
|
||||
|
||||
MessageRecord getMessageRecord();
|
||||
|
@ -18,7 +18,6 @@ package org.thoughtcrime.securesms;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.res.ColorStateList;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Typeface;
|
||||
import android.graphics.drawable.RippleDrawable;
|
||||
import android.os.Build.VERSION;
|
||||
@ -32,11 +31,9 @@ import android.text.TextUtils;
|
||||
import android.text.style.StyleSpan;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.RelativeLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import com.amulyakhare.textdrawable.TextDrawable;
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.thoughtcrime.securesms.components.AlertView;
|
||||
@ -51,6 +48,8 @@ import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientModifiedListener;
|
||||
import org.thoughtcrime.securesms.search.model.MessageResult;
|
||||
import org.thoughtcrime.securesms.util.DateUtils;
|
||||
import org.thoughtcrime.securesms.util.SearchUtil;
|
||||
import org.thoughtcrime.securesms.util.SearchUtil.StyleFactory;
|
||||
import org.thoughtcrime.securesms.util.ThemeUtil;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
@ -60,8 +59,6 @@ import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.thoughtcrime.securesms.util.SpanUtil.color;
|
||||
|
||||
public class ConversationListItem extends RelativeLayout
|
||||
implements RecipientModifiedListener,
|
||||
BindableConversationListItem, Unbindable
|
||||
@ -150,7 +147,7 @@ public class ConversationListItem extends RelativeLayout
|
||||
|
||||
this.recipient.addListener(this);
|
||||
if (highlightSubstring != null) {
|
||||
this.fromView.setText(getHighlightedSpan(locale, recipient.getName(), highlightSubstring));
|
||||
this.fromView.setText(SearchUtil.getHighlightedSpan(locale, () -> new StyleSpan(Typeface.BOLD), recipient.getName(), highlightSubstring));
|
||||
} else {
|
||||
this.fromView.setText(recipient, unreadCount == 0);
|
||||
}
|
||||
@ -204,8 +201,8 @@ public class ConversationListItem extends RelativeLayout
|
||||
|
||||
this.recipient.addListener(this);
|
||||
|
||||
fromView.setText(getHighlightedSpan(locale, recipient.getName(), highlightSubstring));
|
||||
subjectView.setText(getHighlightedSpan(locale, contact.getAddress().toPhoneString(), highlightSubstring));
|
||||
fromView.setText(SearchUtil.getHighlightedSpan(locale, () -> new StyleSpan(Typeface.BOLD), recipient.getName(), highlightSubstring));
|
||||
subjectView.setText(SearchUtil.getHighlightedSpan(locale, () -> new StyleSpan(Typeface.BOLD), contact.getAddress().toPhoneString(), highlightSubstring));
|
||||
dateView.setText("");
|
||||
archivedView.setVisibility(GONE);
|
||||
unreadIndicator.setVisibility(GONE);
|
||||
@ -224,13 +221,13 @@ public class ConversationListItem extends RelativeLayout
|
||||
@Nullable String highlightSubstring)
|
||||
{
|
||||
this.selectedThreads = Collections.emptySet();
|
||||
this.recipient = messageResult.recipient;
|
||||
this.recipient = messageResult.conversationRecipient;
|
||||
this.glideRequests = glideRequests;
|
||||
|
||||
this.recipient.addListener(this);
|
||||
|
||||
fromView.setText(recipient, true);
|
||||
subjectView.setText(getHighlightedSpan(locale, messageResult.bodySnippet, highlightSubstring));
|
||||
subjectView.setText(SearchUtil.getHighlightedSpan(locale, () -> new StyleSpan(Typeface.BOLD), messageResult.bodySnippet, highlightSubstring));
|
||||
dateView.setText(DateUtils.getBriefRelativeTimeSpanString(getContext(), locale, messageResult.receivedTimestampMs));
|
||||
archivedView.setVisibility(GONE);
|
||||
unreadIndicator.setVisibility(GONE);
|
||||
@ -333,44 +330,6 @@ public class ConversationListItem extends RelativeLayout
|
||||
unreadIndicator.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
private Spanned getHighlightedSpan(@NonNull Locale locale,
|
||||
@Nullable String value,
|
||||
@Nullable String highlight)
|
||||
{
|
||||
if (TextUtils.isEmpty(value)) {
|
||||
return new SpannableString("");
|
||||
}
|
||||
|
||||
value = value.replaceAll("\n", " ");
|
||||
|
||||
if (TextUtils.isEmpty(highlight)) {
|
||||
return new SpannableString(value);
|
||||
}
|
||||
|
||||
String normalizedValue = value.toLowerCase(locale);
|
||||
String normalizedTest = highlight.toLowerCase(locale);
|
||||
List<String> testTokens = Stream.of(normalizedTest.split(" ")).filter(s -> s.trim().length() > 0).toList();
|
||||
|
||||
Spannable spanned = new SpannableString(value);
|
||||
int searchStartIndex = 0;
|
||||
|
||||
for (String token : testTokens) {
|
||||
if (searchStartIndex >= spanned.length()) {
|
||||
break;
|
||||
}
|
||||
|
||||
int start = normalizedValue.indexOf(token, searchStartIndex);
|
||||
|
||||
if (start >= 0) {
|
||||
int end = Math.min(start + token.length(), spanned.length());
|
||||
spanned.setSpan(new StyleSpan(Typeface.BOLD), start, end, Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
|
||||
searchStartIndex = end;
|
||||
}
|
||||
}
|
||||
|
||||
return spanned;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onModified(final Recipient recipient) {
|
||||
Util.runOnMain(() -> {
|
||||
|
@ -96,6 +96,7 @@ public class DatabaseUpgradeActivity extends BaseActivity {
|
||||
public static final int COLOR_MIGRATION = 412;
|
||||
public static final int UNIDENTIFIED_DELIVERY = 422;
|
||||
public static final int SIGNALING_KEY_DEPRECATION = 447;
|
||||
public static final int CONVERSATION_SEARCH = 455;
|
||||
|
||||
private static final SortedSet<Integer> UPGRADE_VERSIONS = new TreeSet<Integer>() {{
|
||||
add(NO_MORE_KEY_EXCHANGE_PREFIX_VERSION);
|
||||
@ -123,6 +124,7 @@ public class DatabaseUpgradeActivity extends BaseActivity {
|
||||
add(COLOR_MIGRATION);
|
||||
add(UNIDENTIFIED_DELIVERY);
|
||||
add(SIGNALING_KEY_DEPRECATION);
|
||||
add(CONVERSATION_SEARCH);
|
||||
}};
|
||||
|
||||
private MasterSecret masterSecret;
|
||||
|
@ -271,7 +271,7 @@ public class MessageDetailsActivity extends PassphraseRequiredActionBarActivity
|
||||
toFromRes = R.string.message_details_header__from;
|
||||
}
|
||||
toFrom.setText(toFromRes);
|
||||
conversationItem.bind(messageRecord, Optional.absent(), Optional.absent(), glideRequests, dynamicLanguage.getCurrentLocale(), new HashSet<>(), recipient, false);
|
||||
conversationItem.bind(messageRecord, Optional.absent(), Optional.absent(), glideRequests, dynamicLanguage.getCurrentLocale(), new HashSet<>(), recipient, null, false);
|
||||
recipientsList.setAdapter(new MessageDetailsRecipientAdapter(this, glideRequests, messageRecord, recipients, isPushGroup));
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,88 @@
|
||||
package org.thoughtcrime.securesms.components;
|
||||
|
||||
import android.content.Context;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.support.constraint.ConstraintLayout;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
|
||||
/**
|
||||
* Bottom navigation bar shown in the {@link org.thoughtcrime.securesms.conversation.ConversationActivity}
|
||||
* when the user is searching within a conversation. Shows details about the results and allows the
|
||||
* user to move between them.
|
||||
*/
|
||||
public class ConversationSearchBottomBar extends ConstraintLayout {
|
||||
|
||||
private View searchDown;
|
||||
private View searchUp;
|
||||
private TextView searchPositionText;
|
||||
private View progressWheel;
|
||||
|
||||
private EventListener eventListener;
|
||||
|
||||
|
||||
public ConversationSearchBottomBar(Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
public ConversationSearchBottomBar(Context context, AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onFinishInflate() {
|
||||
super.onFinishInflate();
|
||||
|
||||
this.searchUp = findViewById(R.id.conversation_search_up);
|
||||
this.searchDown = findViewById(R.id.conversation_search_down);
|
||||
this.searchPositionText = findViewById(R.id.conversation_search_position);
|
||||
this.progressWheel = findViewById(R.id.conversation_search_progress_wheel);
|
||||
}
|
||||
|
||||
public void setData(int position, int count) {
|
||||
progressWheel.setVisibility(GONE);
|
||||
|
||||
searchUp.setOnClickListener(v -> {
|
||||
if (eventListener != null) {
|
||||
eventListener.onSearchMoveUpPressed();
|
||||
}
|
||||
});
|
||||
|
||||
searchDown.setOnClickListener(v -> {
|
||||
if (eventListener != null) {
|
||||
eventListener.onSearchMoveDownPressed();
|
||||
}
|
||||
});
|
||||
|
||||
if (count > 0) {
|
||||
searchPositionText.setText(getResources().getString(R.string.ConversationActivity_search_position, position + 1, count));
|
||||
} else {
|
||||
searchPositionText.setText(R.string.ConversationActivity_no_results);
|
||||
}
|
||||
|
||||
setViewEnabled(searchUp, position < (count - 1));
|
||||
setViewEnabled(searchDown, position > 0);
|
||||
}
|
||||
|
||||
public void showLoading() {
|
||||
progressWheel.setVisibility(VISIBLE);
|
||||
}
|
||||
|
||||
private void setViewEnabled(@NonNull View view, boolean enabled) {
|
||||
view.setEnabled(enabled);
|
||||
view.setAlpha(enabled ? 1f : 0.25f);
|
||||
}
|
||||
|
||||
public void setEventListener(@Nullable EventListener eventListener) {
|
||||
this.eventListener = eventListener;
|
||||
}
|
||||
|
||||
public interface EventListener {
|
||||
void onSearchMoveUpPressed();
|
||||
void onSearchMoveDownPressed();
|
||||
}
|
||||
}
|
@ -49,6 +49,7 @@ import android.support.v4.view.MenuItemCompat;
|
||||
import android.support.v4.view.WindowCompat;
|
||||
import android.support.v7.app.ActionBar;
|
||||
import android.support.v7.app.AlertDialog;
|
||||
import android.support.v7.widget.SearchView;
|
||||
import android.text.Editable;
|
||||
import android.text.TextUtils;
|
||||
import android.text.TextWatcher;
|
||||
@ -97,6 +98,7 @@ import org.thoughtcrime.securesms.color.MaterialColor;
|
||||
import org.thoughtcrime.securesms.components.AnimatingToggle;
|
||||
import org.thoughtcrime.securesms.components.AttachmentTypeSelector;
|
||||
import org.thoughtcrime.securesms.components.ComposeText;
|
||||
import org.thoughtcrime.securesms.components.ConversationSearchBottomBar;
|
||||
import org.thoughtcrime.securesms.components.HidingLinearLayout;
|
||||
import org.thoughtcrime.securesms.components.InputAwareLayout;
|
||||
import org.thoughtcrime.securesms.components.InputPanel;
|
||||
@ -179,6 +181,7 @@ import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientFormattingException;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientModifiedListener;
|
||||
import org.thoughtcrime.securesms.scribbles.ScribbleActivity;
|
||||
import org.thoughtcrime.securesms.search.model.MessageResult;
|
||||
import org.thoughtcrime.securesms.service.KeyCachingService;
|
||||
import org.thoughtcrime.securesms.sms.MessageSender;
|
||||
import org.thoughtcrime.securesms.sms.OutgoingEncryptedMessage;
|
||||
@ -235,7 +238,8 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
OnKeyboardShownListener,
|
||||
AttachmentDrawerListener,
|
||||
InputPanel.Listener,
|
||||
InputPanel.MediaListener
|
||||
InputPanel.MediaListener,
|
||||
ConversationSearchBottomBar.EventListener
|
||||
{
|
||||
private static final String TAG = ConversationActivity.class.getSimpleName();
|
||||
|
||||
@ -280,6 +284,8 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
private Stub<UnverifiedBannerView> unverifiedBannerView;
|
||||
private Stub<GroupShareProfileView> groupShareProfileView;
|
||||
private TypingStatusTextWatcher typingTextWatcher;
|
||||
private ConversationSearchBottomBar searchNav;
|
||||
private MenuItem searchViewItem;
|
||||
|
||||
private AttachmentTypeSelector attachmentTypeSelector;
|
||||
private AttachmentManager attachmentManager;
|
||||
@ -290,7 +296,9 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
protected HidingLinearLayout inlineAttachmentToggle;
|
||||
private QuickAttachmentDrawer quickAttachmentDrawer;
|
||||
private InputPanel inputPanel;
|
||||
private LinkPreviewViewModel linkPreviewViewModel;
|
||||
|
||||
private LinkPreviewViewModel linkPreviewViewModel;
|
||||
private ConversationSearchViewModel searchViewModel;
|
||||
|
||||
private Recipient recipient;
|
||||
private long threadId;
|
||||
@ -331,6 +339,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
initializeViews();
|
||||
initializeResources();
|
||||
initializeLinkPreviewObserver();
|
||||
initializeSearchObserver();
|
||||
initializeSecurity(false, isDefaultSms).addListener(new AssertedSuccessListener<Boolean>() {
|
||||
@Override
|
||||
public void onSuccess(Boolean result) {
|
||||
@ -643,6 +652,56 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
inflater.inflate(R.menu.conversation_add_to_contacts, menu);
|
||||
}
|
||||
|
||||
searchViewItem = menu.findItem(R.id.menu_search);
|
||||
|
||||
SearchView searchView = (SearchView) searchViewItem.getActionView();
|
||||
SearchView.OnQueryTextListener queryListener = new SearchView.OnQueryTextListener() {
|
||||
@Override
|
||||
public boolean onQueryTextSubmit(String query) {
|
||||
searchViewModel.onQueryUpdated(query, threadId);
|
||||
searchNav.showLoading();
|
||||
fragment.onSearchQueryUpdated(query);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onQueryTextChange(String query) {
|
||||
searchViewModel.onQueryUpdated(query, threadId);
|
||||
searchNav.showLoading();
|
||||
fragment.onSearchQueryUpdated(query);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
searchViewItem.setOnActionExpandListener(new MenuItem.OnActionExpandListener() {
|
||||
@Override
|
||||
public boolean onMenuItemActionExpand(MenuItem item) {
|
||||
searchView.setOnQueryTextListener(queryListener);
|
||||
searchViewModel.onSearchOpened();
|
||||
searchNav.setVisibility(View.VISIBLE);
|
||||
searchNav.setData(0, 0);
|
||||
inputPanel.setVisibility(View.GONE);
|
||||
|
||||
for (int i = 0; i < menu.size(); i++) {
|
||||
if (!menu.getItem(i).equals(searchViewItem)) {
|
||||
menu.getItem(i).setVisible(false);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onMenuItemActionCollapse(MenuItem item) {
|
||||
searchView.setOnQueryTextListener(null);
|
||||
searchViewModel.onSearchClosed();
|
||||
searchNav.setVisibility(View.GONE);
|
||||
inputPanel.setVisibility(View.VISIBLE);
|
||||
fragment.onSearchQueryUpdated(null);
|
||||
invalidateOptionsMenu();
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
super.onPrepareOptionsMenu(menu);
|
||||
return true;
|
||||
}
|
||||
@ -655,6 +714,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
case R.id.menu_call_insecure: handleDial(getRecipient(), false); return true;
|
||||
case R.id.menu_view_media: handleViewMedia(); return true;
|
||||
case R.id.menu_add_shortcut: handleAddShortcut(); return true;
|
||||
case R.id.menu_search: handleSearch(); return true;
|
||||
case R.id.menu_add_to_contacts: handleAddToContacts(); return true;
|
||||
case R.id.menu_reset_secure_session: handleResetSecureSession(); return true;
|
||||
case R.id.menu_group_recipients: handleDisplayGroupRecipients(); return true;
|
||||
@ -920,6 +980,10 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
}.execute();
|
||||
}
|
||||
|
||||
private void handleSearch() {
|
||||
searchViewModel.onSearchOpened();
|
||||
}
|
||||
|
||||
private void handleLeavePushGroup() {
|
||||
if (getRecipient() == null) {
|
||||
Toast.makeText(this, getString(R.string.ConversationActivity_invalid_recipient),
|
||||
@ -1438,6 +1502,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
quickAttachmentToggle = ViewUtil.findById(this, R.id.quick_attachment_toggle);
|
||||
inlineAttachmentToggle = ViewUtil.findById(this, R.id.inline_attachment_container);
|
||||
inputPanel = ViewUtil.findById(this, R.id.bottom_panel);
|
||||
searchNav = ViewUtil.findById(this, R.id.conversation_search_nav);
|
||||
|
||||
ImageButton quickCameraToggle = ViewUtil.findById(this, R.id.quick_camera_toggle);
|
||||
ImageButton inlineAttachmentButton = ViewUtil.findById(this, R.id.inline_attachment_button);
|
||||
@ -1489,6 +1554,8 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
quickCameraToggle.setEnabled(false);
|
||||
}
|
||||
|
||||
searchNav.setEventListener(this);
|
||||
|
||||
inlineAttachmentButton.setOnClickListener(v -> handleAddAttachment());
|
||||
}
|
||||
|
||||
@ -1544,6 +1611,30 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
});
|
||||
}
|
||||
|
||||
private void initializeSearchObserver() {
|
||||
searchViewModel = ViewModelProviders.of(this).get(ConversationSearchViewModel.class);
|
||||
|
||||
searchViewModel.getSearchResults().observe(this, result -> {
|
||||
if (result == null) return;
|
||||
|
||||
if (!result.getResults().isEmpty()) {
|
||||
MessageResult messageResult = result.getResults().get(result.getPosition());
|
||||
fragment.jumpToMessage(messageResult.messageRecipient.getAddress(), messageResult.receivedTimestampMs, searchViewModel::onMissingResult);
|
||||
}
|
||||
|
||||
searchNav.setData(result.getPosition(), result.getResults().size());
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSearchMoveUpPressed() {
|
||||
searchViewModel.onMoveUp();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSearchMoveDownPressed() {
|
||||
searchViewModel.onMoveDown();
|
||||
}
|
||||
|
||||
private void initializeProfiles() {
|
||||
if (!isSecureText) {
|
||||
@ -1569,7 +1660,10 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
updateReminders(recipient.hasSeenInviteReminder());
|
||||
updateDefaultSubscriptionId(recipient.getDefaultSubscriptionId());
|
||||
initializeSecurity(isSecureText, isDefaultSms);
|
||||
invalidateOptionsMenu();
|
||||
|
||||
if (!searchViewItem.isActionViewExpanded()) {
|
||||
invalidateOptionsMenu();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -2445,6 +2539,11 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessageActionToolbarOpened() {
|
||||
searchViewItem.collapseActionView();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAttachmentChanged() {
|
||||
handleSecurityChange(isSecureText, isDefaultSms);
|
||||
|
@ -105,6 +105,7 @@ public class ConversationAdapter <V extends View & BindableConversationItem>
|
||||
private final @NonNull MessageDigest digest;
|
||||
|
||||
private MessageRecord recordToPulseHighlight;
|
||||
private String searchQuery;
|
||||
|
||||
protected static class ViewHolder extends RecyclerView.ViewHolder {
|
||||
public <V extends View & BindableConversationItem> ViewHolder(final @NonNull V itemView) {
|
||||
@ -205,6 +206,7 @@ public class ConversationAdapter <V extends View & BindableConversationItem>
|
||||
locale,
|
||||
batchSelected,
|
||||
recipient,
|
||||
searchQuery,
|
||||
messageRecord == recordToPulseHighlight);
|
||||
|
||||
if (messageRecord == recordToPulseHighlight) {
|
||||
@ -363,6 +365,11 @@ public class ConversationAdapter <V extends View & BindableConversationItem>
|
||||
}
|
||||
}
|
||||
|
||||
public void onSearchQueryUpdated(@Nullable String query) {
|
||||
this.searchQuery = query;
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
private boolean hasAudio(MessageRecord messageRecord) {
|
||||
return messageRecord.isMms() && ((MmsMessageRecord)messageRecord).getSlideDeck().getAudioSlide() != null;
|
||||
}
|
||||
|
@ -27,6 +27,7 @@ import android.os.AsyncTask;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.support.v4.app.ActivityCompat;
|
||||
import android.support.v4.app.ActivityOptionsCompat;
|
||||
import android.support.v4.app.Fragment;
|
||||
@ -49,6 +50,7 @@ import org.thoughtcrime.securesms.ShareActivity;
|
||||
import org.thoughtcrime.securesms.attachments.Attachment;
|
||||
import org.thoughtcrime.securesms.components.ConversationTypingView;
|
||||
import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager;
|
||||
import org.thoughtcrime.securesms.database.Address;
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
|
||||
@ -94,6 +96,7 @@ import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
|
||||
import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
@ -126,6 +129,7 @@ public class ConversationFragment extends Fragment
|
||||
private long lastSeen;
|
||||
private int startingPosition;
|
||||
private int previousOffset;
|
||||
private int activeOffset;
|
||||
private boolean firstLoad;
|
||||
private long loaderStartTime;
|
||||
private ActionMode actionMode;
|
||||
@ -631,9 +635,14 @@ public class ConversationFragment extends Fragment
|
||||
|
||||
if (loader.hasOffset()) {
|
||||
adapter.setHeaderView(bottomLoadMoreView);
|
||||
}
|
||||
|
||||
if (firstLoad || loader.hasOffset()) {
|
||||
previousOffset = loader.getOffset();
|
||||
}
|
||||
|
||||
activeOffset = loader.getOffset();
|
||||
|
||||
adapter.changeCursor(cursor);
|
||||
|
||||
int lastSeenPosition = adapter.findLastSeenPosition(lastSeen);
|
||||
@ -734,9 +743,42 @@ public class ConversationFragment extends Fragment
|
||||
return firstVisiblePosition == 0 && list.getChildAt(0).getBottom() <= list.getHeight();
|
||||
}
|
||||
|
||||
public void onSearchQueryUpdated(@Nullable String query) {
|
||||
getListAdapter().onSearchQueryUpdated(query);
|
||||
}
|
||||
|
||||
public void jumpToMessage(@NonNull Address author, long timestamp, @Nullable Runnable onMessageNotFound) {
|
||||
SimpleTask.run(getLifecycle(), () -> {
|
||||
return DatabaseFactory.getMmsSmsDatabase(getContext())
|
||||
.getMessagePositionInConversation(threadId, timestamp, author);
|
||||
}, p -> moveToMessagePosition(p, onMessageNotFound));
|
||||
}
|
||||
|
||||
private void moveToMessagePosition(int position, @Nullable Runnable onMessageNotFound) {
|
||||
Log.d(TAG, "Moving to message position: " + position + " activeOffset: " + activeOffset + " cursorCount: " + getListAdapter().getCursorCount());
|
||||
|
||||
if (position >= activeOffset && position >= 0 && position < getListAdapter().getCursorCount()) {
|
||||
int offset = activeOffset > 0 ? activeOffset - 1 : 0;
|
||||
list.scrollToPosition(position - offset);
|
||||
getListAdapter().pulseHighlightItem(position - offset);
|
||||
} else if (position < 0) {
|
||||
Log.w(TAG, "Tried to navigate to message, but it wasn't found.");
|
||||
if (onMessageNotFound != null) {
|
||||
onMessageNotFound.run();
|
||||
}
|
||||
} else {
|
||||
Log.i(TAG, "Message was outside of the loaded range. Need to restart the loader.");
|
||||
|
||||
firstLoad = true;
|
||||
startingPosition = position;
|
||||
getLoaderManager().restartLoader(0, Bundle.EMPTY, ConversationFragment.this);
|
||||
}
|
||||
}
|
||||
|
||||
public interface ConversationFragmentListener {
|
||||
void setThreadId(long threadId);
|
||||
void handleReplyMessage(MessageRecord messageRecord);
|
||||
void onMessageActionToolbarOpened();
|
||||
}
|
||||
|
||||
private class ConversationScrollListener extends OnScrollListener {
|
||||
@ -848,41 +890,14 @@ public class ConversationFragment extends Fragment
|
||||
return;
|
||||
}
|
||||
|
||||
new AsyncTask<Void, Void, Integer>() {
|
||||
@Override
|
||||
protected Integer doInBackground(Void... voids) {
|
||||
if (getActivity() == null || getActivity().isFinishing()) {
|
||||
Log.w(TAG, "Task to retrieve quote position started after the fragment was detached.");
|
||||
return 0;
|
||||
}
|
||||
return DatabaseFactory.getMmsSmsDatabase(getContext())
|
||||
.getQuotedMessagePosition(threadId,
|
||||
messageRecord.getQuote().getId(),
|
||||
messageRecord.getQuote().getAuthor());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPostExecute(Integer position) {
|
||||
if (getActivity() == null || getActivity().isFinishing()) {
|
||||
Log.w(TAG, "Task to retrieve quote position finished after the fragment was detached.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (position >= 0 && position < getListAdapter().getItemCount()) {
|
||||
list.scrollToPosition(position);
|
||||
getListAdapter().pulseHighlightItem(position);
|
||||
} else if (position < 0) {
|
||||
Log.w(TAG, "Tried to navigate to quoted message, but it was deleted.");
|
||||
Toast.makeText(getContext(), R.string.ConversationFragment_quoted_message_no_longer_available, Toast.LENGTH_SHORT).show();
|
||||
} else {
|
||||
Log.i(TAG, "Quoted message was outside of the loaded range. Need to restart the loader.");
|
||||
|
||||
firstLoad = true;
|
||||
startingPosition = position;
|
||||
getLoaderManager().restartLoader(0, Bundle.EMPTY, ConversationFragment.this);
|
||||
}
|
||||
}
|
||||
}.execute();
|
||||
SimpleTask.run(getLifecycle(), () -> {
|
||||
return DatabaseFactory.getMmsSmsDatabase(getContext())
|
||||
.getQuotedMessagePosition(threadId,
|
||||
messageRecord.getQuote().getId(),
|
||||
messageRecord.getQuote().getAuthor());
|
||||
}, p -> moveToMessagePosition(p, () -> {
|
||||
Toast.makeText(getContext(), R.string.ConversationFragment_quoted_message_no_longer_available, Toast.LENGTH_SHORT).show();
|
||||
}));
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -965,6 +980,7 @@ public class ConversationFragment extends Fragment
|
||||
}
|
||||
|
||||
setCorrectMenuVisibility(menu);
|
||||
listener.onMessageActionToolbarOpened();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
@ -28,9 +28,12 @@ import android.support.annotation.DimenRes;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.support.v7.app.AlertDialog;
|
||||
import android.text.Spannable;
|
||||
import android.text.SpannableString;
|
||||
import android.text.Spanned;
|
||||
import android.text.TextUtils;
|
||||
import android.text.style.BackgroundColorSpan;
|
||||
import android.text.style.ForegroundColorSpan;
|
||||
import android.text.style.URLSpan;
|
||||
import android.text.util.Linkify;
|
||||
import android.util.AttributeSet;
|
||||
@ -87,6 +90,7 @@ import org.thoughtcrime.securesms.util.DateUtils;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
import org.thoughtcrime.securesms.util.LongClickCopySpan;
|
||||
import org.thoughtcrime.securesms.util.LongClickMovementMethod;
|
||||
import org.thoughtcrime.securesms.util.SearchUtil;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
@ -206,6 +210,7 @@ public class ConversationItem extends LinearLayout
|
||||
@NonNull Locale locale,
|
||||
@NonNull Set<MessageRecord> batchSelected,
|
||||
@NonNull Recipient conversationRecipient,
|
||||
@Nullable String searchQuery,
|
||||
boolean pulseHighlight)
|
||||
{
|
||||
this.messageRecord = messageRecord;
|
||||
@ -223,7 +228,7 @@ public class ConversationItem extends LinearLayout
|
||||
setMessageShape(messageRecord, previousMessageRecord, nextMessageRecord, groupThread);
|
||||
setMediaAttributes(messageRecord, previousMessageRecord, nextMessageRecord, conversationRecipient, groupThread);
|
||||
setInteractionState(messageRecord, pulseHighlight);
|
||||
setBodyText(messageRecord);
|
||||
setBodyText(messageRecord, searchQuery);
|
||||
setBubbleState(messageRecord);
|
||||
setStatusIcons(messageRecord);
|
||||
setContactPhoto(recipient);
|
||||
@ -401,7 +406,7 @@ public class ConversationItem extends LinearLayout
|
||||
return messageRecord.isMms() && !((MmsMessageRecord)messageRecord).getLinkPreviews().isEmpty();
|
||||
}
|
||||
|
||||
private void setBodyText(MessageRecord messageRecord) {
|
||||
private void setBodyText(MessageRecord messageRecord, @Nullable String searchQuery) {
|
||||
bodyText.setClickable(false);
|
||||
bodyText.setFocusable(false);
|
||||
bodyText.setTextSize(TypedValue.COMPLEX_UNIT_SP, TextSecurePreferences.getMessageBodyTextSize(context));
|
||||
@ -409,7 +414,11 @@ public class ConversationItem extends LinearLayout
|
||||
if (isCaptionlessMms(messageRecord)) {
|
||||
bodyText.setVisibility(View.GONE);
|
||||
} else {
|
||||
bodyText.setText(linkifyMessageBody(messageRecord.getDisplayBody(), batchSelected.isEmpty()));
|
||||
Spannable styledText = linkifyMessageBody(messageRecord.getDisplayBody(), batchSelected.isEmpty());
|
||||
styledText = SearchUtil.getHighlightedSpan(locale, () -> new BackgroundColorSpan(Color.YELLOW), styledText, searchQuery);
|
||||
styledText = SearchUtil.getHighlightedSpan(locale, () -> new ForegroundColorSpan(Color.BLACK), styledText, searchQuery);
|
||||
|
||||
bodyText.setText(styledText);
|
||||
bodyText.setVisibility(View.VISIBLE);
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,147 @@
|
||||
package org.thoughtcrime.securesms.conversation;
|
||||
|
||||
import android.app.Application;
|
||||
import android.arch.lifecycle.AndroidViewModel;
|
||||
import android.arch.lifecycle.LiveData;
|
||||
import android.content.Context;
|
||||
import android.os.AsyncTask;
|
||||
import android.support.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.contacts.ContactAccessor;
|
||||
import org.thoughtcrime.securesms.database.CursorList;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.search.SearchRepository;
|
||||
import org.thoughtcrime.securesms.search.model.MessageResult;
|
||||
import org.thoughtcrime.securesms.util.CloseableLiveData;
|
||||
import org.thoughtcrime.securesms.util.Debouncer;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.util.List;
|
||||
|
||||
public class ConversationSearchViewModel extends AndroidViewModel {
|
||||
|
||||
private final SearchRepository searchRepository;
|
||||
private final CloseableLiveData<SearchResult> result;
|
||||
private final Debouncer debouncer;
|
||||
|
||||
private boolean firstSearch;
|
||||
private boolean searchOpen;
|
||||
private String activeQuery;
|
||||
private long activeThreadId;
|
||||
|
||||
public ConversationSearchViewModel(@NonNull Application application) {
|
||||
super(application);
|
||||
Context context = application.getApplicationContext();
|
||||
result = new CloseableLiveData<>();
|
||||
debouncer = new Debouncer(500);
|
||||
searchRepository = new SearchRepository(context,
|
||||
DatabaseFactory.getSearchDatabase(context),
|
||||
DatabaseFactory.getContactsDatabase(context),
|
||||
DatabaseFactory.getThreadDatabase(context),
|
||||
ContactAccessor.getInstance(),
|
||||
AsyncTask.THREAD_POOL_EXECUTOR);
|
||||
}
|
||||
|
||||
LiveData<SearchResult> getSearchResults() {
|
||||
return result;
|
||||
}
|
||||
|
||||
void onQueryUpdated(@NonNull String query, long threadId) {
|
||||
if (firstSearch && query.length() < 2) {
|
||||
result.postValue(new SearchResult(CursorList.emptyList(), 0));
|
||||
return;
|
||||
}
|
||||
|
||||
if (query.equals(activeQuery)) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateQuery(query, threadId);
|
||||
}
|
||||
|
||||
void onMissingResult() {
|
||||
if (activeQuery != null) {
|
||||
updateQuery(activeQuery, activeThreadId);
|
||||
}
|
||||
}
|
||||
|
||||
void onMoveUp() {
|
||||
debouncer.clear();
|
||||
|
||||
CursorList<MessageResult> messages = (CursorList<MessageResult>) result.getValue().getResults();
|
||||
int position = Math.min(result.getValue().getPosition() + 1, messages.size() - 1);
|
||||
|
||||
result.setValue(new SearchResult(messages, position), false);
|
||||
}
|
||||
|
||||
void onMoveDown() {
|
||||
debouncer.clear();
|
||||
|
||||
CursorList<MessageResult> messages = (CursorList<MessageResult>) result.getValue().getResults();
|
||||
int position = Math.max(result.getValue().getPosition() - 1, 0);
|
||||
|
||||
result.setValue(new SearchResult(messages, position), false);
|
||||
}
|
||||
|
||||
|
||||
void onSearchOpened() {
|
||||
searchOpen = true;
|
||||
firstSearch = true;
|
||||
}
|
||||
|
||||
void onSearchClosed() {
|
||||
searchOpen = false;
|
||||
debouncer.clear();
|
||||
result.close();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCleared() {
|
||||
super.onCleared();
|
||||
result.close();
|
||||
}
|
||||
|
||||
private void updateQuery(@NonNull String query, long threadId) {
|
||||
activeQuery = query;
|
||||
activeThreadId = threadId;
|
||||
|
||||
debouncer.publish(() -> {
|
||||
firstSearch = false;
|
||||
|
||||
searchRepository.query(query, threadId, messages -> {
|
||||
Util.runOnMain(() -> {
|
||||
if (searchOpen && query.equals(activeQuery)) {
|
||||
result.setValue(new SearchResult(messages, 0));
|
||||
} else {
|
||||
messages.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
static class SearchResult implements Closeable {
|
||||
|
||||
private final CursorList<MessageResult> results;
|
||||
private final int position;
|
||||
|
||||
SearchResult(CursorList<MessageResult> results, int position) {
|
||||
this.results = results;
|
||||
this.position = position;
|
||||
}
|
||||
|
||||
public List<MessageResult> getResults() {
|
||||
return results;
|
||||
}
|
||||
|
||||
public int getPosition() {
|
||||
return position;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
results.close();
|
||||
}
|
||||
}
|
||||
}
|
@ -79,6 +79,7 @@ public class ConversationUpdateItem extends LinearLayout
|
||||
@NonNull Locale locale,
|
||||
@NonNull Set<MessageRecord> batchSelected,
|
||||
@NonNull Recipient conversationRecipient,
|
||||
@Nullable String searchQuery,
|
||||
boolean pulseUpdate)
|
||||
{
|
||||
this.batchSelected = batchSelected;
|
||||
|
@ -30,6 +30,8 @@ public class CursorList<T> implements List<T>, Closeable {
|
||||
public CursorList(@NonNull Cursor cursor, @NonNull ModelBuilder<T> modelBuilder) {
|
||||
this.cursor = cursor;
|
||||
this.modelBuilder = modelBuilder;
|
||||
|
||||
forceQueryLoad();
|
||||
}
|
||||
|
||||
public static <T> CursorList<T> emptyList() {
|
||||
@ -195,6 +197,10 @@ public class CursorList<T> implements List<T>, Closeable {
|
||||
cursor.unregisterContentObserver(observer);
|
||||
}
|
||||
|
||||
private void forceQueryLoad() {
|
||||
cursor.getCount();
|
||||
}
|
||||
|
||||
public interface ModelBuilder<T> {
|
||||
T build(@NonNull Cursor cursor);
|
||||
}
|
||||
|
@ -124,6 +124,10 @@ public abstract class CursorRecyclerViewAdapter<VH extends RecyclerView.ViewHold
|
||||
+ (hasFooterView() ? 1 : 0);
|
||||
}
|
||||
|
||||
public int getCursorCount() {
|
||||
return cursor.getCount();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@Override
|
||||
public final void onViewRecycled(ViewHolder holder) {
|
||||
@ -190,7 +194,7 @@ public abstract class CursorRecyclerViewAdapter<VH extends RecyclerView.ViewHold
|
||||
throw new IllegalStateException("this should only be called when the cursor is valid");
|
||||
}
|
||||
if (!cursor.moveToPosition(getCursorPosition(position))) {
|
||||
throw new IllegalStateException("couldn't move cursor to position " + position);
|
||||
throw new IllegalStateException("couldn't move cursor to position " + position + " (actual cursor position " + getCursorPosition(position) + ")");
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
@ -183,6 +183,26 @@ public class MmsSmsDatabase extends Database {
|
||||
return -1;
|
||||
}
|
||||
|
||||
public int getMessagePositionInConversation(long threadId, long receivedTimestamp, @NonNull Address address) {
|
||||
String order = MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " DESC";
|
||||
String selection = MmsSmsColumns.THREAD_ID + " = " + threadId;
|
||||
|
||||
try (Cursor cursor = queryTables(new String[]{ MmsSmsColumns.NORMALIZED_DATE_RECEIVED, MmsSmsColumns.ADDRESS }, selection, order, null)) {
|
||||
String serializedAddress = address.serialize();
|
||||
boolean isOwnNumber = Util.isOwnNumber(context, address);
|
||||
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
boolean timestampMatches = cursor.getLong(0) == receivedTimestamp;
|
||||
boolean addressMatches = serializedAddress.equals(cursor.getString(1));
|
||||
|
||||
if (timestampMatches && (addressMatches || isOwnNumber)) {
|
||||
return cursor.getPosition();
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the position of the message with the provided timestamp in the query results you'd
|
||||
* get from calling {@link #getConversation(long)}.
|
||||
|
@ -21,78 +21,122 @@ public class SearchDatabase extends Database {
|
||||
public static final String SMS_FTS_TABLE_NAME = "sms_fts";
|
||||
public static final String MMS_FTS_TABLE_NAME = "mms_fts";
|
||||
|
||||
public static final String ID = "rowid";
|
||||
public static final String BODY = MmsSmsColumns.BODY;
|
||||
public static final String RANK = "rank";
|
||||
public static final String SNIPPET = "snippet";
|
||||
public static final String ID = "rowid";
|
||||
public static final String BODY = MmsSmsColumns.BODY;
|
||||
public static final String THREAD_ID = MmsSmsColumns.THREAD_ID;
|
||||
public static final String SNIPPET = "snippet";
|
||||
public static final String CONVERSATION_ADDRESS = "conversation_address";
|
||||
public static final String MESSAGE_ADDRESS = "message_address";
|
||||
|
||||
public static final String[] CREATE_TABLE = {
|
||||
"CREATE VIRTUAL TABLE " + SMS_FTS_TABLE_NAME + " USING fts5(" + BODY + ", content=" + SmsDatabase.TABLE_NAME + ", content_rowid=" + SmsDatabase.ID + ");",
|
||||
"CREATE VIRTUAL TABLE " + SMS_FTS_TABLE_NAME + " USING fts5(" + BODY + ", " + THREAD_ID + " UNINDEXED, content=" + SmsDatabase.TABLE_NAME + ", content_rowid=" + SmsDatabase.ID + ");",
|
||||
|
||||
"CREATE TRIGGER sms_ai AFTER INSERT ON " + SmsDatabase.TABLE_NAME + " BEGIN\n" +
|
||||
" INSERT INTO " + SMS_FTS_TABLE_NAME + "(" + ID + ", " + BODY + ") VALUES (new." + SmsDatabase.ID + ", new." + SmsDatabase.BODY + ");\n" +
|
||||
" INSERT INTO " + SMS_FTS_TABLE_NAME + "(" + ID + ", " + BODY + ", " + THREAD_ID + ") VALUES (new." + SmsDatabase.ID + ", new." + SmsDatabase.BODY + ", new." + SmsDatabase.THREAD_ID + ");\n" +
|
||||
"END;\n",
|
||||
"CREATE TRIGGER sms_ad AFTER DELETE ON " + SmsDatabase.TABLE_NAME + " BEGIN\n" +
|
||||
" INSERT INTO " + SMS_FTS_TABLE_NAME + "(" + SMS_FTS_TABLE_NAME + ", " + ID + ", " + BODY + ") VALUES('delete', old." + SmsDatabase.ID + ", old." + SmsDatabase.BODY + ");\n" +
|
||||
" INSERT INTO " + SMS_FTS_TABLE_NAME + "(" + SMS_FTS_TABLE_NAME + ", " + ID + ", " + BODY + ", " + THREAD_ID + ") VALUES('delete', old." + SmsDatabase.ID + ", old." + SmsDatabase.BODY + ", old." + SmsDatabase.THREAD_ID + ");\n" +
|
||||
"END;\n",
|
||||
"CREATE TRIGGER sms_au AFTER UPDATE ON " + SmsDatabase.TABLE_NAME + " BEGIN\n" +
|
||||
" INSERT INTO " + SMS_FTS_TABLE_NAME + "(" + SMS_FTS_TABLE_NAME + ", " + ID + ", " + BODY + ") VALUES('delete', old." + SmsDatabase.ID + ", old." + SmsDatabase.BODY + ");\n" +
|
||||
" INSERT INTO " + SMS_FTS_TABLE_NAME + "(" + ID + ", " + BODY + ") VALUES(new." + SmsDatabase.ID + ", new." + SmsDatabase.BODY + ");\n" +
|
||||
" INSERT INTO " + SMS_FTS_TABLE_NAME + "(" + SMS_FTS_TABLE_NAME + ", " + ID + ", " + BODY + ", " + THREAD_ID + ") VALUES('delete', old." + SmsDatabase.ID + ", old." + SmsDatabase.BODY + ", old." + SmsDatabase.THREAD_ID + ");\n" +
|
||||
" INSERT INTO " + SMS_FTS_TABLE_NAME + "(" + ID + ", " + BODY + ", " + THREAD_ID + ") VALUES(new." + SmsDatabase.ID + ", new." + SmsDatabase.BODY + ", new." + SmsDatabase.THREAD_ID + ");\n" +
|
||||
"END;",
|
||||
|
||||
|
||||
"CREATE VIRTUAL TABLE " + MMS_FTS_TABLE_NAME + " USING fts5(" + BODY + ", content=" + MmsDatabase.TABLE_NAME + ", content_rowid=" + MmsDatabase.ID + ");",
|
||||
"CREATE VIRTUAL TABLE " + MMS_FTS_TABLE_NAME + " USING fts5(" + BODY + ", " + THREAD_ID + " UNINDEXED, content=" + MmsDatabase.TABLE_NAME + ", content_rowid=" + MmsDatabase.ID + ");",
|
||||
|
||||
"CREATE TRIGGER mms_ai AFTER INSERT ON " + MmsDatabase.TABLE_NAME + " BEGIN\n" +
|
||||
" INSERT INTO " + MMS_FTS_TABLE_NAME + "(" + ID + ", " + BODY + ") VALUES (new." + MmsDatabase.ID + ", new." + MmsDatabase.BODY + ");\n" +
|
||||
" INSERT INTO " + MMS_FTS_TABLE_NAME + "(" + ID + ", " + BODY + ", " + THREAD_ID + ") VALUES (new." + MmsDatabase.ID + ", new." + MmsDatabase.BODY + ", new." + MmsDatabase.THREAD_ID + ");\n" +
|
||||
"END;\n",
|
||||
"CREATE TRIGGER mms_ad AFTER DELETE ON " + MmsDatabase.TABLE_NAME + " BEGIN\n" +
|
||||
" INSERT INTO " + MMS_FTS_TABLE_NAME + "(" + MMS_FTS_TABLE_NAME + ", " + ID + ", " + BODY + ") VALUES('delete', old." + MmsDatabase.ID + ", old." + MmsDatabase.BODY + ");\n" +
|
||||
" INSERT INTO " + MMS_FTS_TABLE_NAME + "(" + MMS_FTS_TABLE_NAME + ", " + ID + ", " + BODY + ", " + THREAD_ID + ") VALUES('delete', old." + MmsDatabase.ID + ", old." + MmsDatabase.BODY + ", old." + MmsDatabase.THREAD_ID + ");\n" +
|
||||
"END;\n",
|
||||
"CREATE TRIGGER mms_au AFTER UPDATE ON " + MmsDatabase.TABLE_NAME + " BEGIN\n" +
|
||||
" INSERT INTO " + MMS_FTS_TABLE_NAME + "(" + MMS_FTS_TABLE_NAME + ", " + ID + ", " + BODY + ") VALUES('delete', old." + MmsDatabase.ID + ", old." + MmsDatabase.BODY + ");\n" +
|
||||
" INSERT INTO " + MMS_FTS_TABLE_NAME + "(" + ID + ", " + BODY + ") VALUES (new." + MmsDatabase.ID + ", new." + MmsDatabase.BODY + ");\n" +
|
||||
" INSERT INTO " + MMS_FTS_TABLE_NAME + "(" + MMS_FTS_TABLE_NAME + ", " + ID + ", " + BODY + ", " + THREAD_ID + ") VALUES('delete', old." + MmsDatabase.ID + ", old." + MmsDatabase.BODY + ", old." + MmsDatabase.THREAD_ID + ");\n" +
|
||||
" INSERT INTO " + MMS_FTS_TABLE_NAME + "(" + ID + ", " + BODY + ", " + THREAD_ID + ") VALUES (new." + MmsDatabase.ID + ", new." + MmsDatabase.BODY + ", new." + MmsDatabase.THREAD_ID + ");\n" +
|
||||
"END;"
|
||||
};
|
||||
|
||||
private static final String MESSAGES_QUERY =
|
||||
"SELECT " +
|
||||
ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ADDRESS + ", " +
|
||||
ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ADDRESS + " AS " + CONVERSATION_ADDRESS + ", " +
|
||||
MmsSmsColumns.ADDRESS + " AS " + MESSAGE_ADDRESS + ", " +
|
||||
"snippet(" + SMS_FTS_TABLE_NAME + ", -1, '', '', '...', 7) AS " + SNIPPET + ", " +
|
||||
SmsDatabase.TABLE_NAME + "." + SmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + ", " +
|
||||
MmsSmsColumns.THREAD_ID + " " +
|
||||
SMS_FTS_TABLE_NAME + "." + THREAD_ID + " " +
|
||||
"FROM " + SmsDatabase.TABLE_NAME + " " +
|
||||
"INNER JOIN " + SMS_FTS_TABLE_NAME + " ON " + SMS_FTS_TABLE_NAME + "." + ID + " = " + SmsDatabase.TABLE_NAME + "." + SmsDatabase.ID + " " +
|
||||
"INNER JOIN " + ThreadDatabase.TABLE_NAME + " ON " + SmsDatabase.TABLE_NAME + "." + MmsSmsColumns.THREAD_ID + " = " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ID + " " +
|
||||
"INNER JOIN " + ThreadDatabase.TABLE_NAME + " ON " + SMS_FTS_TABLE_NAME + "." + THREAD_ID + " = " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ID + " " +
|
||||
"WHERE " + SMS_FTS_TABLE_NAME + " MATCH ? " +
|
||||
"UNION ALL " +
|
||||
"SELECT " +
|
||||
ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ADDRESS + ", " +
|
||||
ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ADDRESS + " AS " + CONVERSATION_ADDRESS + ", " +
|
||||
MmsSmsColumns.ADDRESS + " AS " + MESSAGE_ADDRESS + ", " +
|
||||
"snippet(" + MMS_FTS_TABLE_NAME + ", -1, '', '', '...', 7) AS " + SNIPPET + ", " +
|
||||
MmsDatabase.TABLE_NAME + "." + MmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + ", " +
|
||||
MmsSmsColumns.THREAD_ID + " " +
|
||||
MMS_FTS_TABLE_NAME + "." + THREAD_ID + " " +
|
||||
"FROM " + MmsDatabase.TABLE_NAME + " " +
|
||||
"INNER JOIN " + MMS_FTS_TABLE_NAME + " ON " + MMS_FTS_TABLE_NAME + "." + ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " " +
|
||||
"INNER JOIN " + ThreadDatabase.TABLE_NAME + " ON " + MmsDatabase.TABLE_NAME + "." + MmsSmsColumns.THREAD_ID + " = " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ID + " " +
|
||||
"INNER JOIN " + ThreadDatabase.TABLE_NAME + " ON " + MMS_FTS_TABLE_NAME + "." + THREAD_ID + " = " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ID + " " +
|
||||
"WHERE " + MMS_FTS_TABLE_NAME + " MATCH ? " +
|
||||
"ORDER BY " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " DESC " +
|
||||
"LIMIT 500";
|
||||
|
||||
private static final String MESSAGES_FOR_THREAD_QUERY =
|
||||
"SELECT " +
|
||||
ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ADDRESS + " AS " + CONVERSATION_ADDRESS + ", " +
|
||||
MmsSmsColumns.ADDRESS + " AS " + MESSAGE_ADDRESS + ", " +
|
||||
"snippet(" + SMS_FTS_TABLE_NAME + ", -1, '', '', '...', 7) AS " + SNIPPET + ", " +
|
||||
SmsDatabase.TABLE_NAME + "." + SmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + ", " +
|
||||
SMS_FTS_TABLE_NAME + "." + THREAD_ID + " " +
|
||||
"FROM " + SmsDatabase.TABLE_NAME + " " +
|
||||
"INNER JOIN " + SMS_FTS_TABLE_NAME + " ON " + SMS_FTS_TABLE_NAME + "." + ID + " = " + SmsDatabase.TABLE_NAME + "." + SmsDatabase.ID + " " +
|
||||
"INNER JOIN " + ThreadDatabase.TABLE_NAME + " ON " + SMS_FTS_TABLE_NAME + "." + THREAD_ID + " = " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ID + " " +
|
||||
"WHERE " + SMS_FTS_TABLE_NAME + " MATCH ? AND " + SmsDatabase.TABLE_NAME + "." + MmsSmsColumns.THREAD_ID + " = ? " +
|
||||
"UNION ALL " +
|
||||
"SELECT " +
|
||||
ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ADDRESS + " AS " + CONVERSATION_ADDRESS + ", " +
|
||||
MmsSmsColumns.ADDRESS + " AS " + MESSAGE_ADDRESS + ", " +
|
||||
"snippet(" + MMS_FTS_TABLE_NAME + ", -1, '', '', '...', 7) AS " + SNIPPET + ", " +
|
||||
MmsDatabase.TABLE_NAME + "." + MmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + ", " +
|
||||
MMS_FTS_TABLE_NAME + "." + THREAD_ID + " " +
|
||||
"FROM " + MmsDatabase.TABLE_NAME + " " +
|
||||
"INNER JOIN " + MMS_FTS_TABLE_NAME + " ON " + MMS_FTS_TABLE_NAME + "." + ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " " +
|
||||
"INNER JOIN " + ThreadDatabase.TABLE_NAME + " ON " + MMS_FTS_TABLE_NAME + "." + THREAD_ID + " = " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.ID + " " +
|
||||
"WHERE " + MMS_FTS_TABLE_NAME + " MATCH ? AND " + MmsDatabase.TABLE_NAME + "." + MmsSmsColumns.THREAD_ID + " = ? " +
|
||||
"ORDER BY " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " DESC " +
|
||||
"LIMIT 500";
|
||||
|
||||
public SearchDatabase(@NonNull Context context, @NonNull SQLCipherOpenHelper databaseHelper) {
|
||||
super(context, databaseHelper);
|
||||
}
|
||||
|
||||
public Cursor queryMessages(@NonNull String query) {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
|
||||
List<String> tokens = Stream.of(query.split(" ")).filter(s -> s.trim().length() > 0).toList();
|
||||
String prefixQuery = Util.join(tokens, "* ");
|
||||
|
||||
prefixQuery += "*";
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
String prefixQuery = adjustQuery(query);
|
||||
|
||||
Cursor cursor = db.rawQuery(MESSAGES_QUERY, new String[] { prefixQuery, prefixQuery });
|
||||
setNotifyConverationListListeners(cursor);
|
||||
return cursor;
|
||||
}
|
||||
|
||||
public Cursor queryMessages(@NonNull String query, long threadId) {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
String prefixQuery = adjustQuery(query);
|
||||
|
||||
Cursor cursor = db.rawQuery(MESSAGES_FOR_THREAD_QUERY, new String[] { prefixQuery, String.valueOf(threadId), prefixQuery, String.valueOf(threadId) });
|
||||
setNotifyConverationListListeners(cursor);
|
||||
return cursor;
|
||||
|
||||
}
|
||||
|
||||
private String adjustQuery(@NonNull String query) {
|
||||
List<String> tokens = Stream.of(query.split(" ")).filter(s -> s.trim().length() > 0).toList();
|
||||
String prefixQuery = Util.join(tokens, "* ");
|
||||
|
||||
prefixQuery += "*";
|
||||
|
||||
return prefixQuery;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -60,8 +60,9 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
|
||||
private static final int ATTACHMENT_CAPTIONS = 14;
|
||||
private static final int ATTACHMENT_CAPTIONS_FIX = 15;
|
||||
private static final int PREVIEWS = 16;
|
||||
private static final int CONVERSATION_SEARCH = 17;
|
||||
|
||||
private static final int DATABASE_VERSION = 16;
|
||||
private static final int DATABASE_VERSION = 17;
|
||||
private static final String DATABASE_NAME = "signal.db";
|
||||
|
||||
private final Context context;
|
||||
@ -201,9 +202,29 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
|
||||
}
|
||||
|
||||
if (oldVersion < FULL_TEXT_SEARCH) {
|
||||
for (String sql : SearchDatabase.CREATE_TABLE) {
|
||||
db.execSQL(sql);
|
||||
}
|
||||
db.execSQL("CREATE VIRTUAL TABLE sms_fts USING fts5(body, content=sms, content_rowid=_id)");
|
||||
db.execSQL("CREATE TRIGGER sms_ai AFTER INSERT ON sms BEGIN\n" +
|
||||
" INSERT INTO sms_fts(rowid, body) VALUES (new._id, new.body);\n" +
|
||||
"END;");
|
||||
db.execSQL("CREATE TRIGGER sms_ad AFTER DELETE ON sms BEGIN\n" +
|
||||
" INSERT INTO sms_fts(sms_fts, rowid, body) VALUES('delete', old._id, old.body);\n" +
|
||||
"END;\n");
|
||||
db.execSQL("CREATE TRIGGER sms_au AFTER UPDATE ON sms BEGIN\n" +
|
||||
" INSERT INTO sms_fts(sms_fts, rowid, body) VALUES('delete', old._id, old.body);\n" +
|
||||
" INSERT INTO sms_fts(rowid, body) VALUES(new._id, new.body);\n" +
|
||||
"END;");
|
||||
|
||||
db.execSQL("CREATE VIRTUAL TABLE mms_fts USING fts5(body, content=mms, content_rowid=_id)");
|
||||
db.execSQL("CREATE TRIGGER mms_ai AFTER INSERT ON mms BEGIN\n" +
|
||||
" INSERT INTO mms_fts(rowid, body) VALUES (new._id, new.body);\n" +
|
||||
"END;");
|
||||
db.execSQL("CREATE TRIGGER mms_ad AFTER DELETE ON mms BEGIN\n" +
|
||||
" INSERT INTO mms_fts(mms_fts, rowid, body) VALUES('delete', old._id, old.body);\n" +
|
||||
"END;\n");
|
||||
db.execSQL("CREATE TRIGGER mms_au AFTER UPDATE ON mms BEGIN\n" +
|
||||
" INSERT INTO mms_fts(mms_fts, rowid, body) VALUES('delete', old._id, old.body);\n" +
|
||||
" INSERT INTO mms_fts(rowid, body) VALUES(new._id, new.body);\n" +
|
||||
"END;");
|
||||
|
||||
Log.i(TAG, "Beginning to build search index.");
|
||||
long start = SystemClock.elapsedRealtime();
|
||||
@ -313,6 +334,55 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
|
||||
db.execSQL("ALTER TABLE mms ADD COLUMN previews TEXT");
|
||||
}
|
||||
|
||||
if (oldVersion < CONVERSATION_SEARCH) {
|
||||
db.execSQL("DROP TABLE sms_fts");
|
||||
db.execSQL("DROP TABLE mms_fts");
|
||||
db.execSQL("DROP TRIGGER sms_ai");
|
||||
db.execSQL("DROP TRIGGER sms_au");
|
||||
db.execSQL("DROP TRIGGER sms_ad");
|
||||
db.execSQL("DROP TRIGGER mms_ai");
|
||||
db.execSQL("DROP TRIGGER mms_au");
|
||||
db.execSQL("DROP TRIGGER mms_ad");
|
||||
|
||||
db.execSQL("CREATE VIRTUAL TABLE sms_fts USING fts5(body, thread_id UNINDEXED, content=sms, content_rowid=_id)");
|
||||
db.execSQL("CREATE TRIGGER sms_ai AFTER INSERT ON sms BEGIN\n" +
|
||||
" INSERT INTO sms_fts(rowid, body, thread_id) VALUES (new._id, new.body, new.thread_id);\n" +
|
||||
"END;");
|
||||
db.execSQL("CREATE TRIGGER sms_ad AFTER DELETE ON sms BEGIN\n" +
|
||||
" INSERT INTO sms_fts(sms_fts, rowid, body, thread_id) VALUES('delete', old._id, old.body, old.thread_id);\n" +
|
||||
"END;\n");
|
||||
db.execSQL("CREATE TRIGGER sms_au AFTER UPDATE ON sms BEGIN\n" +
|
||||
" INSERT INTO sms_fts(sms_fts, rowid, body, thread_id) VALUES('delete', old._id, old.body, old.thread_id);\n" +
|
||||
" INSERT INTO sms_fts(rowid, body, thread_id) VALUES(new._id, new.body, new.thread_id);\n" +
|
||||
"END;");
|
||||
|
||||
db.execSQL("CREATE VIRTUAL TABLE mms_fts USING fts5(body, thread_id UNINDEXED, content=mms, content_rowid=_id)");
|
||||
db.execSQL("CREATE TRIGGER mms_ai AFTER INSERT ON mms BEGIN\n" +
|
||||
" INSERT INTO mms_fts(rowid, body, thread_id) VALUES (new._id, new.body, new.thread_id);\n" +
|
||||
"END;");
|
||||
db.execSQL("CREATE TRIGGER mms_ad AFTER DELETE ON mms BEGIN\n" +
|
||||
" INSERT INTO mms_fts(mms_fts, rowid, body, thread_id) VALUES('delete', old._id, old.body, old.thread_id);\n" +
|
||||
"END;\n");
|
||||
db.execSQL("CREATE TRIGGER mms_au AFTER UPDATE ON mms BEGIN\n" +
|
||||
" INSERT INTO mms_fts(mms_fts, rowid, body, thread_id) VALUES('delete', old._id, old.body, old.thread_id);\n" +
|
||||
" INSERT INTO mms_fts(rowid, body, thread_id) VALUES(new._id, new.body, new.thread_id);\n" +
|
||||
"END;");
|
||||
|
||||
Log.i(TAG, "Beginning to build search index.");
|
||||
long start = SystemClock.elapsedRealtime();
|
||||
|
||||
db.execSQL("INSERT INTO sms_fts (rowid, body, thread_id) SELECT _id, body, thread_id FROM sms");
|
||||
|
||||
long smsFinished = SystemClock.elapsedRealtime();
|
||||
Log.i(TAG, "Indexing SMS completed in " + (smsFinished - start) + " ms");
|
||||
|
||||
db.execSQL("INSERT INTO mms_fts (rowid, body, thread_id) SELECT _id, body, thread_id FROM mms");
|
||||
|
||||
long mmsFinished = SystemClock.elapsedRealtime();
|
||||
Log.i(TAG, "Indexing MMS completed in " + (mmsFinished - smsFinished) + " ms");
|
||||
Log.i(TAG, "Indexing finished. Total time: " + (mmsFinished - start) + " ms");
|
||||
}
|
||||
|
||||
db.setTransactionSuccessful();
|
||||
} finally {
|
||||
db.endTransaction();
|
||||
|
@ -172,7 +172,7 @@ public class SearchFragment extends Fragment implements SearchListAdapter.EventL
|
||||
ConversationListActivity conversationList = (ConversationListActivity) getActivity();
|
||||
if (conversationList != null) {
|
||||
conversationList.openConversation(message.threadId,
|
||||
message.recipient,
|
||||
message.conversationRecipient,
|
||||
ThreadDatabase.DistributionTypes.DEFAULT,
|
||||
-1,
|
||||
startingPosition);
|
||||
|
@ -19,11 +19,14 @@ import org.thoughtcrime.securesms.database.MmsSmsColumns;
|
||||
import org.thoughtcrime.securesms.database.SearchDatabase;
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.ThreadRecord;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.search.model.MessageResult;
|
||||
import org.thoughtcrime.securesms.search.model.SearchResult;
|
||||
import org.thoughtcrime.securesms.util.Stopwatch;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
@ -32,7 +35,9 @@ import java.util.concurrent.Executor;
|
||||
/**
|
||||
* Manages data retrieval for search.
|
||||
*/
|
||||
class SearchRepository {
|
||||
public class SearchRepository {
|
||||
|
||||
private static final String TAG = SearchRepository.class.getSimpleName();
|
||||
|
||||
private static final Set<Character> BANNED_CHARACTERS = new HashSet<>();
|
||||
static {
|
||||
@ -58,12 +63,12 @@ class SearchRepository {
|
||||
private final ContactAccessor contactAccessor;
|
||||
private final Executor executor;
|
||||
|
||||
SearchRepository(@NonNull Context context,
|
||||
@NonNull SearchDatabase searchDatabase,
|
||||
@NonNull ContactsDatabase contactsDatabase,
|
||||
@NonNull ThreadDatabase threadDatabase,
|
||||
@NonNull ContactAccessor contactAccessor,
|
||||
@NonNull Executor executor)
|
||||
public SearchRepository(@NonNull Context context,
|
||||
@NonNull SearchDatabase searchDatabase,
|
||||
@NonNull ContactsDatabase contactsDatabase,
|
||||
@NonNull ThreadDatabase threadDatabase,
|
||||
@NonNull ContactAccessor contactAccessor,
|
||||
@NonNull Executor executor)
|
||||
{
|
||||
this.context = context.getApplicationContext();
|
||||
this.searchDatabase = searchDatabase;
|
||||
@ -73,22 +78,48 @@ class SearchRepository {
|
||||
this.executor = executor;
|
||||
}
|
||||
|
||||
void query(@NonNull String query, @NonNull Callback callback) {
|
||||
public void query(@NonNull String query, @NonNull Callback<SearchResult> callback) {
|
||||
if (TextUtils.isEmpty(query)) {
|
||||
callback.onResult(SearchResult.EMPTY);
|
||||
return;
|
||||
}
|
||||
|
||||
executor.execute(() -> {
|
||||
String cleanQuery = sanitizeQuery(query);
|
||||
CursorList<Recipient> contacts = queryContacts(cleanQuery);
|
||||
CursorList<ThreadRecord> conversations = queryConversations(cleanQuery);
|
||||
CursorList<MessageResult> messages = queryMessages(cleanQuery);
|
||||
Stopwatch timer = new Stopwatch("FtsQuery");
|
||||
|
||||
String cleanQuery = sanitizeQuery(query);
|
||||
timer.split("clean");
|
||||
|
||||
CursorList<Recipient> contacts = queryContacts(cleanQuery);
|
||||
timer.split("contacts");
|
||||
|
||||
CursorList<ThreadRecord> conversations = queryConversations(cleanQuery);
|
||||
timer.split("conversations");
|
||||
|
||||
CursorList<MessageResult> messages = queryMessages(cleanQuery);
|
||||
timer.split("messages");
|
||||
|
||||
timer.stop(TAG);
|
||||
|
||||
callback.onResult(new SearchResult(cleanQuery, contacts, conversations, messages));
|
||||
});
|
||||
}
|
||||
|
||||
public void query(@NonNull String query, long threadId, @NonNull Callback<CursorList<MessageResult>> callback) {
|
||||
if (TextUtils.isEmpty(query)) {
|
||||
callback.onResult(CursorList.emptyList());
|
||||
return;
|
||||
}
|
||||
|
||||
executor.execute(() -> {
|
||||
long startTime = System.currentTimeMillis();
|
||||
CursorList<MessageResult> messages = queryMessages(sanitizeQuery(query), threadId);
|
||||
Log.d(TAG, "[ConversationQuery] " + (System.currentTimeMillis() - startTime) + " ms");
|
||||
|
||||
callback.onResult(messages);
|
||||
});
|
||||
}
|
||||
|
||||
private CursorList<Recipient> queryContacts(String query) {
|
||||
if (!Permissions.hasAny(context, Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)) {
|
||||
return CursorList.emptyList();
|
||||
@ -116,6 +147,12 @@ class SearchRepository {
|
||||
: CursorList.emptyList();
|
||||
}
|
||||
|
||||
private CursorList<MessageResult> queryMessages(@NonNull String query, long threadId) {
|
||||
Cursor messages = searchDatabase.queryMessages(query, threadId);
|
||||
return messages != null ? new CursorList<>(messages, new MessageModelBuilder(context))
|
||||
: CursorList.emptyList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Unfortunately {@link DatabaseUtils#sqlEscapeString(String)} is not sufficient for our purposes.
|
||||
* MATCH queries have a separate format of their own that disallow most "special" characters.
|
||||
@ -177,17 +214,19 @@ class SearchRepository {
|
||||
|
||||
@Override
|
||||
public MessageResult build(@NonNull Cursor cursor) {
|
||||
Address address = Address.fromSerialized(cursor.getString(0));
|
||||
Recipient recipient = Recipient.from(context, address, false);
|
||||
String body = cursor.getString(cursor.getColumnIndexOrThrow(SearchDatabase.SNIPPET));
|
||||
long receivedMs = cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.NORMALIZED_DATE_RECEIVED));
|
||||
long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.THREAD_ID));
|
||||
Address conversationAddress = Address.fromSerialized(cursor.getString(cursor.getColumnIndex(SearchDatabase.CONVERSATION_ADDRESS)));
|
||||
Address messageAddress = Address.fromSerialized(cursor.getString(cursor.getColumnIndexOrThrow(SearchDatabase.MESSAGE_ADDRESS)));
|
||||
Recipient conversationRecipient = Recipient.from(context, conversationAddress, false);
|
||||
Recipient messageRecipient = Recipient.from(context, messageAddress, false);
|
||||
String body = cursor.getString(cursor.getColumnIndexOrThrow(SearchDatabase.SNIPPET));
|
||||
long receivedMs = cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.NORMALIZED_DATE_RECEIVED));
|
||||
long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.THREAD_ID));
|
||||
|
||||
return new MessageResult(recipient, body, threadId, receivedMs);
|
||||
return new MessageResult(conversationRecipient, messageRecipient, body, threadId, receivedMs);
|
||||
}
|
||||
}
|
||||
|
||||
public interface Callback {
|
||||
void onResult(@NonNull SearchResult result);
|
||||
public interface Callback<E> {
|
||||
void onResult(@NonNull E result);
|
||||
}
|
||||
}
|
||||
|
@ -11,6 +11,7 @@ import android.text.TextUtils;
|
||||
|
||||
import org.thoughtcrime.securesms.search.model.SearchResult;
|
||||
import org.thoughtcrime.securesms.util.Debouncer;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
|
||||
/**
|
||||
* A {@link ViewModel} for handling all the business logic and interactions that take place inside
|
||||
@ -28,7 +29,7 @@ class SearchViewModel extends ViewModel {
|
||||
|
||||
private String lastQuery;
|
||||
|
||||
SearchViewModel(@NonNull SearchRepository searchRepository) {
|
||||
private SearchViewModel(@NonNull SearchRepository searchRepository) {
|
||||
this.searchResult = new ObservingLiveData();
|
||||
this.searchRepository = searchRepository;
|
||||
this.debouncer = new Debouncer(500);
|
||||
@ -49,7 +50,15 @@ class SearchViewModel extends ViewModel {
|
||||
|
||||
void updateQuery(String query) {
|
||||
lastQuery = query;
|
||||
debouncer.publish(() -> searchRepository.query(query, searchResult::postValue));
|
||||
debouncer.publish(() -> searchRepository.query(query, result -> {
|
||||
Util.runOnMain(() -> {
|
||||
if (query.equals(lastQuery)) {
|
||||
searchResult.setValue(result);
|
||||
} else {
|
||||
result.close();
|
||||
}
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
@NonNull
|
||||
|
@ -9,19 +9,22 @@ import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
*/
|
||||
public class MessageResult {
|
||||
|
||||
public final Recipient recipient;
|
||||
public final Recipient conversationRecipient;
|
||||
public final Recipient messageRecipient;
|
||||
public final String bodySnippet;
|
||||
public final long threadId;
|
||||
public final long receivedTimestampMs;
|
||||
|
||||
public MessageResult(@NonNull Recipient recipient,
|
||||
public MessageResult(@NonNull Recipient conversationRecipient,
|
||||
@NonNull Recipient messageRecipient,
|
||||
@NonNull String bodySnippet,
|
||||
long threadId,
|
||||
long receivedTimestampMs)
|
||||
{
|
||||
this.recipient = recipient;
|
||||
this.bodySnippet = bodySnippet;
|
||||
this.threadId = threadId;
|
||||
this.receivedTimestampMs = receivedTimestampMs;
|
||||
this.conversationRecipient = conversationRecipient;
|
||||
this.messageRecipient = messageRecipient;
|
||||
this.bodySnippet = bodySnippet;
|
||||
this.threadId = threadId;
|
||||
this.receivedTimestampMs = receivedTimestampMs;
|
||||
}
|
||||
}
|
||||
|
35
src/org/thoughtcrime/securesms/util/CloseableLiveData.java
Normal file
35
src/org/thoughtcrime/securesms/util/CloseableLiveData.java
Normal file
@ -0,0 +1,35 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.arch.lifecycle.MutableLiveData;
|
||||
|
||||
import java.io.Closeable;
|
||||
|
||||
/**
|
||||
* Implementation of {@link android.arch.lifecycle.LiveData} that will handle closing the contained
|
||||
* {@link Closeable} when the value changes.
|
||||
*/
|
||||
public class CloseableLiveData<E extends Closeable> extends MutableLiveData<E> {
|
||||
|
||||
@Override
|
||||
public void setValue(E value) {
|
||||
setValue(value, true);
|
||||
}
|
||||
|
||||
public void setValue(E value, boolean closePrevious) {
|
||||
E previous = getValue();
|
||||
|
||||
if (previous != null && closePrevious) {
|
||||
Util.close(previous);
|
||||
}
|
||||
|
||||
super.setValue(value);
|
||||
}
|
||||
|
||||
public void close() {
|
||||
E value = getValue();
|
||||
|
||||
if (value != null) {
|
||||
Util.close(value);
|
||||
}
|
||||
}
|
||||
}
|
96
src/org/thoughtcrime/securesms/util/SearchUtil.java
Normal file
96
src/org/thoughtcrime/securesms/util/SearchUtil.java
Normal file
@ -0,0 +1,96 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.text.Spannable;
|
||||
import android.text.SpannableString;
|
||||
import android.text.Spanned;
|
||||
import android.text.TextUtils;
|
||||
import android.text.style.CharacterStyle;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.whispersystems.libsignal.util.Pair;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
public class SearchUtil {
|
||||
|
||||
public static Spannable getHighlightedSpan(@NonNull Locale locale,
|
||||
@NonNull StyleFactory styleFactory,
|
||||
@Nullable String text,
|
||||
@Nullable String highlight)
|
||||
{
|
||||
if (TextUtils.isEmpty(text)) {
|
||||
return new SpannableString("");
|
||||
}
|
||||
|
||||
text = text.replaceAll("\n", " ");
|
||||
|
||||
return getHighlightedSpan(locale, styleFactory, new SpannableString(text), highlight);
|
||||
}
|
||||
|
||||
public static Spannable getHighlightedSpan(@NonNull Locale locale,
|
||||
@NonNull StyleFactory styleFactory,
|
||||
@Nullable Spannable text,
|
||||
@Nullable String highlight)
|
||||
{
|
||||
if (TextUtils.isEmpty(text)) {
|
||||
return new SpannableString("");
|
||||
}
|
||||
|
||||
|
||||
if (TextUtils.isEmpty(highlight)) {
|
||||
return text;
|
||||
}
|
||||
|
||||
List<Pair<Integer, Integer>> ranges = getHighlightRanges(locale, text.toString(), highlight);
|
||||
SpannableString spanned = new SpannableString(text);
|
||||
|
||||
for (Pair<Integer, Integer> range : ranges) {
|
||||
spanned.setSpan(styleFactory.create(), range.first(), range.second(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
|
||||
}
|
||||
|
||||
return spanned;
|
||||
}
|
||||
|
||||
static List<Pair<Integer, Integer>> getHighlightRanges(@NonNull Locale locale,
|
||||
@NonNull String text,
|
||||
@NonNull String highlight)
|
||||
{
|
||||
String normalizedText = text.toLowerCase(locale);
|
||||
String normalizedHighlight = highlight.toLowerCase(locale);
|
||||
List<String> highlightTokens = Stream.of(normalizedHighlight.split("\\s")).filter(s -> s.trim().length() > 0).toList();
|
||||
List<String> textTokens = Stream.of(normalizedText.split("\\s")).filter(s -> s.trim().length() > 0).toList();
|
||||
|
||||
List<Pair<Integer, Integer>> ranges = new LinkedList<>();
|
||||
|
||||
int textListIndex = 0;
|
||||
int textCharIndex = 0;
|
||||
|
||||
for (String highlightToken : highlightTokens) {
|
||||
for (int i = textListIndex; i < textTokens.size(); i++) {
|
||||
if (textTokens.get(i).startsWith(highlightToken)) {
|
||||
textListIndex = i + 1;
|
||||
ranges.add(new Pair<>(textCharIndex, textCharIndex + highlightToken.length()));
|
||||
textCharIndex += textTokens.get(i).length() + 1;
|
||||
break;
|
||||
}
|
||||
textCharIndex += textTokens.get(i).length() + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (ranges.size() != highlightTokens.size()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
public interface StyleFactory {
|
||||
CharacterStyle create();
|
||||
}
|
||||
}
|
@ -0,0 +1,65 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.whispersystems.libsignal.util.Pair;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class SearchUtilTest {
|
||||
|
||||
private static final Locale LOCALE = Locale.ENGLISH;
|
||||
|
||||
@Test
|
||||
public void getHighlightRanges_singleHighlightToken() {
|
||||
String text = "abc";
|
||||
String highlight = "a";
|
||||
List<Pair<Integer, Integer>> result = SearchUtil.getHighlightRanges(LOCALE, text, highlight);
|
||||
|
||||
assertEquals(Arrays.asList(new Pair<>(0, 1)), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getHighlightRanges_multipleHighlightTokens() {
|
||||
String text = "a bc";
|
||||
String highlight = "a b";
|
||||
List<Pair<Integer, Integer>> result = SearchUtil.getHighlightRanges(LOCALE, text, highlight);
|
||||
|
||||
assertEquals(Arrays.asList(new Pair<>(0, 1), new Pair<>(2, 3)), result);
|
||||
|
||||
|
||||
text = "abc def";
|
||||
highlight = "ab de";
|
||||
result = SearchUtil.getHighlightRanges(LOCALE, text, highlight);
|
||||
|
||||
assertEquals(Arrays.asList(new Pair<>(0, 2), new Pair<>(4, 6)), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getHighlightRanges_onlyHighlightPrefixes() {
|
||||
String text = "abc";
|
||||
String highlight = "b";
|
||||
List<Pair<Integer, Integer>> result = SearchUtil.getHighlightRanges(LOCALE, text, highlight);
|
||||
|
||||
assertTrue(result.isEmpty());
|
||||
|
||||
text = "abc";
|
||||
highlight = "c";
|
||||
result = SearchUtil.getHighlightRanges(LOCALE, text, highlight);
|
||||
|
||||
assertTrue(result.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getHighlightRanges_resultNotInFirstToken() {
|
||||
String text = "abc def ghi";
|
||||
String highlight = "gh";
|
||||
List<Pair<Integer, Integer>> result = SearchUtil.getHighlightRanges(LOCALE, text, highlight);
|
||||
|
||||
assertEquals(Arrays.asList(new Pair<>(8, 10)), result);
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user