mirror of
https://github.com/oxen-io/session-android.git
synced 2025-04-28 02:20:46 +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,8 +54,16 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"/>
|
android:layout_height="wrap_content"/>
|
||||||
|
|
||||||
|
<FrameLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content">
|
||||||
|
|
||||||
<include layout="@layout/conversation_input_panel"/>
|
<include layout="@layout/conversation_input_panel"/>
|
||||||
|
|
||||||
|
<include layout="@layout/conversation_search_nav" />
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
<Button android:id="@+id/register_button"
|
<Button android:id="@+id/register_button"
|
||||||
android:layout_width="fill_parent"
|
android:layout_width="fill_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
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"?>
|
<?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"
|
<item android:title="@string/conversation__menu_view_all_media"
|
||||||
android:id="@+id/menu_view_media" />
|
android:id="@+id/menu_view_media" />
|
||||||
@ -7,6 +8,12 @@
|
|||||||
<item android:title="@string/conversation__menu_conversation_settings"
|
<item android:title="@string/conversation__menu_conversation_settings"
|
||||||
android:id="@+id/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"
|
<item android:title="@string/conversation__menu_add_shortcut"
|
||||||
android:id="@+id/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_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_yes">Yes</string>
|
||||||
<string name="ConversationActivity_no">No</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 -->
|
<!-- ConversationAdapter -->
|
||||||
<plurals name="ConversationAdapter_n_unread_messages">
|
<plurals name="ConversationAdapter_n_unread_messages">
|
||||||
|
@ -24,6 +24,7 @@ public interface BindableConversationItem extends Unbindable {
|
|||||||
@NonNull Locale locale,
|
@NonNull Locale locale,
|
||||||
@NonNull Set<MessageRecord> batchSelected,
|
@NonNull Set<MessageRecord> batchSelected,
|
||||||
@NonNull Recipient recipients,
|
@NonNull Recipient recipients,
|
||||||
|
@Nullable String searchQuery,
|
||||||
boolean pulseHighlight);
|
boolean pulseHighlight);
|
||||||
|
|
||||||
MessageRecord getMessageRecord();
|
MessageRecord getMessageRecord();
|
||||||
|
@ -18,7 +18,6 @@ package org.thoughtcrime.securesms;
|
|||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.res.ColorStateList;
|
import android.content.res.ColorStateList;
|
||||||
import android.graphics.Color;
|
|
||||||
import android.graphics.Typeface;
|
import android.graphics.Typeface;
|
||||||
import android.graphics.drawable.RippleDrawable;
|
import android.graphics.drawable.RippleDrawable;
|
||||||
import android.os.Build.VERSION;
|
import android.os.Build.VERSION;
|
||||||
@ -32,11 +31,9 @@ import android.text.TextUtils;
|
|||||||
import android.text.style.StyleSpan;
|
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.RelativeLayout;
|
import android.widget.RelativeLayout;
|
||||||
import android.widget.TextView;
|
import android.widget.TextView;
|
||||||
|
|
||||||
import com.amulyakhare.textdrawable.TextDrawable;
|
|
||||||
import com.annimon.stream.Stream;
|
import com.annimon.stream.Stream;
|
||||||
|
|
||||||
import org.thoughtcrime.securesms.components.AlertView;
|
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.recipients.RecipientModifiedListener;
|
||||||
import org.thoughtcrime.securesms.search.model.MessageResult;
|
import org.thoughtcrime.securesms.search.model.MessageResult;
|
||||||
import org.thoughtcrime.securesms.util.DateUtils;
|
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.ThemeUtil;
|
||||||
import org.thoughtcrime.securesms.util.Util;
|
import org.thoughtcrime.securesms.util.Util;
|
||||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||||
@ -60,8 +59,6 @@ import java.util.List;
|
|||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
import static org.thoughtcrime.securesms.util.SpanUtil.color;
|
|
||||||
|
|
||||||
public class ConversationListItem extends RelativeLayout
|
public class ConversationListItem extends RelativeLayout
|
||||||
implements RecipientModifiedListener,
|
implements RecipientModifiedListener,
|
||||||
BindableConversationListItem, Unbindable
|
BindableConversationListItem, Unbindable
|
||||||
@ -150,7 +147,7 @@ public class ConversationListItem extends RelativeLayout
|
|||||||
|
|
||||||
this.recipient.addListener(this);
|
this.recipient.addListener(this);
|
||||||
if (highlightSubstring != null) {
|
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 {
|
} else {
|
||||||
this.fromView.setText(recipient, unreadCount == 0);
|
this.fromView.setText(recipient, unreadCount == 0);
|
||||||
}
|
}
|
||||||
@ -204,8 +201,8 @@ public class ConversationListItem extends RelativeLayout
|
|||||||
|
|
||||||
this.recipient.addListener(this);
|
this.recipient.addListener(this);
|
||||||
|
|
||||||
fromView.setText(getHighlightedSpan(locale, recipient.getName(), highlightSubstring));
|
fromView.setText(SearchUtil.getHighlightedSpan(locale, () -> new StyleSpan(Typeface.BOLD), recipient.getName(), highlightSubstring));
|
||||||
subjectView.setText(getHighlightedSpan(locale, contact.getAddress().toPhoneString(), highlightSubstring));
|
subjectView.setText(SearchUtil.getHighlightedSpan(locale, () -> new StyleSpan(Typeface.BOLD), contact.getAddress().toPhoneString(), highlightSubstring));
|
||||||
dateView.setText("");
|
dateView.setText("");
|
||||||
archivedView.setVisibility(GONE);
|
archivedView.setVisibility(GONE);
|
||||||
unreadIndicator.setVisibility(GONE);
|
unreadIndicator.setVisibility(GONE);
|
||||||
@ -224,13 +221,13 @@ public class ConversationListItem extends RelativeLayout
|
|||||||
@Nullable String highlightSubstring)
|
@Nullable String highlightSubstring)
|
||||||
{
|
{
|
||||||
this.selectedThreads = Collections.emptySet();
|
this.selectedThreads = Collections.emptySet();
|
||||||
this.recipient = messageResult.recipient;
|
this.recipient = messageResult.conversationRecipient;
|
||||||
this.glideRequests = glideRequests;
|
this.glideRequests = glideRequests;
|
||||||
|
|
||||||
this.recipient.addListener(this);
|
this.recipient.addListener(this);
|
||||||
|
|
||||||
fromView.setText(recipient, true);
|
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));
|
dateView.setText(DateUtils.getBriefRelativeTimeSpanString(getContext(), locale, messageResult.receivedTimestampMs));
|
||||||
archivedView.setVisibility(GONE);
|
archivedView.setVisibility(GONE);
|
||||||
unreadIndicator.setVisibility(GONE);
|
unreadIndicator.setVisibility(GONE);
|
||||||
@ -333,44 +330,6 @@ 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 (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
|
@Override
|
||||||
public void onModified(final Recipient recipient) {
|
public void onModified(final Recipient recipient) {
|
||||||
Util.runOnMain(() -> {
|
Util.runOnMain(() -> {
|
||||||
|
@ -96,6 +96,7 @@ public class DatabaseUpgradeActivity extends BaseActivity {
|
|||||||
public static final int COLOR_MIGRATION = 412;
|
public static final int COLOR_MIGRATION = 412;
|
||||||
public static final int UNIDENTIFIED_DELIVERY = 422;
|
public static final int UNIDENTIFIED_DELIVERY = 422;
|
||||||
public static final int SIGNALING_KEY_DEPRECATION = 447;
|
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>() {{
|
private static final SortedSet<Integer> UPGRADE_VERSIONS = new TreeSet<Integer>() {{
|
||||||
add(NO_MORE_KEY_EXCHANGE_PREFIX_VERSION);
|
add(NO_MORE_KEY_EXCHANGE_PREFIX_VERSION);
|
||||||
@ -123,6 +124,7 @@ public class DatabaseUpgradeActivity extends BaseActivity {
|
|||||||
add(COLOR_MIGRATION);
|
add(COLOR_MIGRATION);
|
||||||
add(UNIDENTIFIED_DELIVERY);
|
add(UNIDENTIFIED_DELIVERY);
|
||||||
add(SIGNALING_KEY_DEPRECATION);
|
add(SIGNALING_KEY_DEPRECATION);
|
||||||
|
add(CONVERSATION_SEARCH);
|
||||||
}};
|
}};
|
||||||
|
|
||||||
private MasterSecret masterSecret;
|
private MasterSecret masterSecret;
|
||||||
|
@ -271,7 +271,7 @@ public class MessageDetailsActivity extends PassphraseRequiredActionBarActivity
|
|||||||
toFromRes = R.string.message_details_header__from;
|
toFromRes = R.string.message_details_header__from;
|
||||||
}
|
}
|
||||||
toFrom.setText(toFromRes);
|
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));
|
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.v4.view.WindowCompat;
|
||||||
import android.support.v7.app.ActionBar;
|
import android.support.v7.app.ActionBar;
|
||||||
import android.support.v7.app.AlertDialog;
|
import android.support.v7.app.AlertDialog;
|
||||||
|
import android.support.v7.widget.SearchView;
|
||||||
import android.text.Editable;
|
import android.text.Editable;
|
||||||
import android.text.TextUtils;
|
import android.text.TextUtils;
|
||||||
import android.text.TextWatcher;
|
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.AnimatingToggle;
|
||||||
import org.thoughtcrime.securesms.components.AttachmentTypeSelector;
|
import org.thoughtcrime.securesms.components.AttachmentTypeSelector;
|
||||||
import org.thoughtcrime.securesms.components.ComposeText;
|
import org.thoughtcrime.securesms.components.ComposeText;
|
||||||
|
import org.thoughtcrime.securesms.components.ConversationSearchBottomBar;
|
||||||
import org.thoughtcrime.securesms.components.HidingLinearLayout;
|
import org.thoughtcrime.securesms.components.HidingLinearLayout;
|
||||||
import org.thoughtcrime.securesms.components.InputAwareLayout;
|
import org.thoughtcrime.securesms.components.InputAwareLayout;
|
||||||
import org.thoughtcrime.securesms.components.InputPanel;
|
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.RecipientFormattingException;
|
||||||
import org.thoughtcrime.securesms.recipients.RecipientModifiedListener;
|
import org.thoughtcrime.securesms.recipients.RecipientModifiedListener;
|
||||||
import org.thoughtcrime.securesms.scribbles.ScribbleActivity;
|
import org.thoughtcrime.securesms.scribbles.ScribbleActivity;
|
||||||
|
import org.thoughtcrime.securesms.search.model.MessageResult;
|
||||||
import org.thoughtcrime.securesms.service.KeyCachingService;
|
import org.thoughtcrime.securesms.service.KeyCachingService;
|
||||||
import org.thoughtcrime.securesms.sms.MessageSender;
|
import org.thoughtcrime.securesms.sms.MessageSender;
|
||||||
import org.thoughtcrime.securesms.sms.OutgoingEncryptedMessage;
|
import org.thoughtcrime.securesms.sms.OutgoingEncryptedMessage;
|
||||||
@ -235,7 +238,8 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
|||||||
OnKeyboardShownListener,
|
OnKeyboardShownListener,
|
||||||
AttachmentDrawerListener,
|
AttachmentDrawerListener,
|
||||||
InputPanel.Listener,
|
InputPanel.Listener,
|
||||||
InputPanel.MediaListener
|
InputPanel.MediaListener,
|
||||||
|
ConversationSearchBottomBar.EventListener
|
||||||
{
|
{
|
||||||
private static final String TAG = ConversationActivity.class.getSimpleName();
|
private static final String TAG = ConversationActivity.class.getSimpleName();
|
||||||
|
|
||||||
@ -280,6 +284,8 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
|||||||
private Stub<UnverifiedBannerView> unverifiedBannerView;
|
private Stub<UnverifiedBannerView> unverifiedBannerView;
|
||||||
private Stub<GroupShareProfileView> groupShareProfileView;
|
private Stub<GroupShareProfileView> groupShareProfileView;
|
||||||
private TypingStatusTextWatcher typingTextWatcher;
|
private TypingStatusTextWatcher typingTextWatcher;
|
||||||
|
private ConversationSearchBottomBar searchNav;
|
||||||
|
private MenuItem searchViewItem;
|
||||||
|
|
||||||
private AttachmentTypeSelector attachmentTypeSelector;
|
private AttachmentTypeSelector attachmentTypeSelector;
|
||||||
private AttachmentManager attachmentManager;
|
private AttachmentManager attachmentManager;
|
||||||
@ -290,7 +296,9 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
|||||||
protected HidingLinearLayout inlineAttachmentToggle;
|
protected HidingLinearLayout inlineAttachmentToggle;
|
||||||
private QuickAttachmentDrawer quickAttachmentDrawer;
|
private QuickAttachmentDrawer quickAttachmentDrawer;
|
||||||
private InputPanel inputPanel;
|
private InputPanel inputPanel;
|
||||||
|
|
||||||
private LinkPreviewViewModel linkPreviewViewModel;
|
private LinkPreviewViewModel linkPreviewViewModel;
|
||||||
|
private ConversationSearchViewModel searchViewModel;
|
||||||
|
|
||||||
private Recipient recipient;
|
private Recipient recipient;
|
||||||
private long threadId;
|
private long threadId;
|
||||||
@ -331,6 +339,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
|||||||
initializeViews();
|
initializeViews();
|
||||||
initializeResources();
|
initializeResources();
|
||||||
initializeLinkPreviewObserver();
|
initializeLinkPreviewObserver();
|
||||||
|
initializeSearchObserver();
|
||||||
initializeSecurity(false, isDefaultSms).addListener(new AssertedSuccessListener<Boolean>() {
|
initializeSecurity(false, isDefaultSms).addListener(new AssertedSuccessListener<Boolean>() {
|
||||||
@Override
|
@Override
|
||||||
public void onSuccess(Boolean result) {
|
public void onSuccess(Boolean result) {
|
||||||
@ -643,6 +652,56 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
|||||||
inflater.inflate(R.menu.conversation_add_to_contacts, menu);
|
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);
|
super.onPrepareOptionsMenu(menu);
|
||||||
return true;
|
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_call_insecure: handleDial(getRecipient(), false); return true;
|
||||||
case R.id.menu_view_media: handleViewMedia(); return true;
|
case R.id.menu_view_media: handleViewMedia(); return true;
|
||||||
case R.id.menu_add_shortcut: handleAddShortcut(); 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_add_to_contacts: handleAddToContacts(); return true;
|
||||||
case R.id.menu_reset_secure_session: handleResetSecureSession(); return true;
|
case R.id.menu_reset_secure_session: handleResetSecureSession(); return true;
|
||||||
case R.id.menu_group_recipients: handleDisplayGroupRecipients(); return true;
|
case R.id.menu_group_recipients: handleDisplayGroupRecipients(); return true;
|
||||||
@ -920,6 +980,10 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
|||||||
}.execute();
|
}.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void handleSearch() {
|
||||||
|
searchViewModel.onSearchOpened();
|
||||||
|
}
|
||||||
|
|
||||||
private void handleLeavePushGroup() {
|
private void handleLeavePushGroup() {
|
||||||
if (getRecipient() == null) {
|
if (getRecipient() == null) {
|
||||||
Toast.makeText(this, getString(R.string.ConversationActivity_invalid_recipient),
|
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);
|
quickAttachmentToggle = ViewUtil.findById(this, R.id.quick_attachment_toggle);
|
||||||
inlineAttachmentToggle = ViewUtil.findById(this, R.id.inline_attachment_container);
|
inlineAttachmentToggle = ViewUtil.findById(this, R.id.inline_attachment_container);
|
||||||
inputPanel = ViewUtil.findById(this, R.id.bottom_panel);
|
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 quickCameraToggle = ViewUtil.findById(this, R.id.quick_camera_toggle);
|
||||||
ImageButton inlineAttachmentButton = ViewUtil.findById(this, R.id.inline_attachment_button);
|
ImageButton inlineAttachmentButton = ViewUtil.findById(this, R.id.inline_attachment_button);
|
||||||
@ -1489,6 +1554,8 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
|||||||
quickCameraToggle.setEnabled(false);
|
quickCameraToggle.setEnabled(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
searchNav.setEventListener(this);
|
||||||
|
|
||||||
inlineAttachmentButton.setOnClickListener(v -> handleAddAttachment());
|
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() {
|
private void initializeProfiles() {
|
||||||
if (!isSecureText) {
|
if (!isSecureText) {
|
||||||
@ -1569,7 +1660,10 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
|||||||
updateReminders(recipient.hasSeenInviteReminder());
|
updateReminders(recipient.hasSeenInviteReminder());
|
||||||
updateDefaultSubscriptionId(recipient.getDefaultSubscriptionId());
|
updateDefaultSubscriptionId(recipient.getDefaultSubscriptionId());
|
||||||
initializeSecurity(isSecureText, isDefaultSms);
|
initializeSecurity(isSecureText, isDefaultSms);
|
||||||
|
|
||||||
|
if (!searchViewItem.isActionViewExpanded()) {
|
||||||
invalidateOptionsMenu();
|
invalidateOptionsMenu();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2445,6 +2539,11 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onMessageActionToolbarOpened() {
|
||||||
|
searchViewItem.collapseActionView();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onAttachmentChanged() {
|
public void onAttachmentChanged() {
|
||||||
handleSecurityChange(isSecureText, isDefaultSms);
|
handleSecurityChange(isSecureText, isDefaultSms);
|
||||||
|
@ -105,6 +105,7 @@ public class ConversationAdapter <V extends View & BindableConversationItem>
|
|||||||
private final @NonNull MessageDigest digest;
|
private final @NonNull MessageDigest digest;
|
||||||
|
|
||||||
private MessageRecord recordToPulseHighlight;
|
private MessageRecord recordToPulseHighlight;
|
||||||
|
private String searchQuery;
|
||||||
|
|
||||||
protected static class ViewHolder extends RecyclerView.ViewHolder {
|
protected static class ViewHolder extends RecyclerView.ViewHolder {
|
||||||
public <V extends View & BindableConversationItem> ViewHolder(final @NonNull V itemView) {
|
public <V extends View & BindableConversationItem> ViewHolder(final @NonNull V itemView) {
|
||||||
@ -205,6 +206,7 @@ public class ConversationAdapter <V extends View & BindableConversationItem>
|
|||||||
locale,
|
locale,
|
||||||
batchSelected,
|
batchSelected,
|
||||||
recipient,
|
recipient,
|
||||||
|
searchQuery,
|
||||||
messageRecord == recordToPulseHighlight);
|
messageRecord == recordToPulseHighlight);
|
||||||
|
|
||||||
if (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) {
|
private boolean hasAudio(MessageRecord messageRecord) {
|
||||||
return messageRecord.isMms() && ((MmsMessageRecord)messageRecord).getSlideDeck().getAudioSlide() != null;
|
return messageRecord.isMms() && ((MmsMessageRecord)messageRecord).getSlideDeck().getAudioSlide() != null;
|
||||||
}
|
}
|
||||||
|
@ -27,6 +27,7 @@ import android.os.AsyncTask;
|
|||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.support.annotation.NonNull;
|
import android.support.annotation.NonNull;
|
||||||
|
import android.support.annotation.Nullable;
|
||||||
import android.support.v4.app.ActivityCompat;
|
import android.support.v4.app.ActivityCompat;
|
||||||
import android.support.v4.app.ActivityOptionsCompat;
|
import android.support.v4.app.ActivityOptionsCompat;
|
||||||
import android.support.v4.app.Fragment;
|
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.attachments.Attachment;
|
||||||
import org.thoughtcrime.securesms.components.ConversationTypingView;
|
import org.thoughtcrime.securesms.components.ConversationTypingView;
|
||||||
import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager;
|
import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager;
|
||||||
|
import org.thoughtcrime.securesms.database.Address;
|
||||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
|
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
|
||||||
import org.thoughtcrime.securesms.logging.Log;
|
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.TextSecurePreferences;
|
||||||
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 org.thoughtcrime.securesms.util.concurrent.SimpleTask;
|
||||||
import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask;
|
import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask;
|
||||||
import org.whispersystems.libsignal.util.guava.Optional;
|
import org.whispersystems.libsignal.util.guava.Optional;
|
||||||
|
|
||||||
@ -126,6 +129,7 @@ public class ConversationFragment extends Fragment
|
|||||||
private long lastSeen;
|
private long lastSeen;
|
||||||
private int startingPosition;
|
private int startingPosition;
|
||||||
private int previousOffset;
|
private int previousOffset;
|
||||||
|
private int activeOffset;
|
||||||
private boolean firstLoad;
|
private boolean firstLoad;
|
||||||
private long loaderStartTime;
|
private long loaderStartTime;
|
||||||
private ActionMode actionMode;
|
private ActionMode actionMode;
|
||||||
@ -631,9 +635,14 @@ public class ConversationFragment extends Fragment
|
|||||||
|
|
||||||
if (loader.hasOffset()) {
|
if (loader.hasOffset()) {
|
||||||
adapter.setHeaderView(bottomLoadMoreView);
|
adapter.setHeaderView(bottomLoadMoreView);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstLoad || loader.hasOffset()) {
|
||||||
previousOffset = loader.getOffset();
|
previousOffset = loader.getOffset();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
activeOffset = loader.getOffset();
|
||||||
|
|
||||||
adapter.changeCursor(cursor);
|
adapter.changeCursor(cursor);
|
||||||
|
|
||||||
int lastSeenPosition = adapter.findLastSeenPosition(lastSeen);
|
int lastSeenPosition = adapter.findLastSeenPosition(lastSeen);
|
||||||
@ -734,9 +743,42 @@ public class ConversationFragment extends Fragment
|
|||||||
return firstVisiblePosition == 0 && list.getChildAt(0).getBottom() <= list.getHeight();
|
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 {
|
public interface ConversationFragmentListener {
|
||||||
void setThreadId(long threadId);
|
void setThreadId(long threadId);
|
||||||
void handleReplyMessage(MessageRecord messageRecord);
|
void handleReplyMessage(MessageRecord messageRecord);
|
||||||
|
void onMessageActionToolbarOpened();
|
||||||
}
|
}
|
||||||
|
|
||||||
private class ConversationScrollListener extends OnScrollListener {
|
private class ConversationScrollListener extends OnScrollListener {
|
||||||
@ -848,41 +890,14 @@ public class ConversationFragment extends Fragment
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
new AsyncTask<Void, Void, Integer>() {
|
SimpleTask.run(getLifecycle(), () -> {
|
||||||
@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())
|
return DatabaseFactory.getMmsSmsDatabase(getContext())
|
||||||
.getQuotedMessagePosition(threadId,
|
.getQuotedMessagePosition(threadId,
|
||||||
messageRecord.getQuote().getId(),
|
messageRecord.getQuote().getId(),
|
||||||
messageRecord.getQuote().getAuthor());
|
messageRecord.getQuote().getAuthor());
|
||||||
}
|
}, p -> moveToMessagePosition(p, () -> {
|
||||||
|
|
||||||
@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();
|
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -965,6 +980,7 @@ public class ConversationFragment extends Fragment
|
|||||||
}
|
}
|
||||||
|
|
||||||
setCorrectMenuVisibility(menu);
|
setCorrectMenuVisibility(menu);
|
||||||
|
listener.onMessageActionToolbarOpened();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -28,9 +28,12 @@ import android.support.annotation.DimenRes;
|
|||||||
import android.support.annotation.NonNull;
|
import android.support.annotation.NonNull;
|
||||||
import android.support.annotation.Nullable;
|
import android.support.annotation.Nullable;
|
||||||
import android.support.v7.app.AlertDialog;
|
import android.support.v7.app.AlertDialog;
|
||||||
|
import android.text.Spannable;
|
||||||
import android.text.SpannableString;
|
import android.text.SpannableString;
|
||||||
import android.text.Spanned;
|
import android.text.Spanned;
|
||||||
import android.text.TextUtils;
|
import android.text.TextUtils;
|
||||||
|
import android.text.style.BackgroundColorSpan;
|
||||||
|
import android.text.style.ForegroundColorSpan;
|
||||||
import android.text.style.URLSpan;
|
import android.text.style.URLSpan;
|
||||||
import android.text.util.Linkify;
|
import android.text.util.Linkify;
|
||||||
import android.util.AttributeSet;
|
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.DynamicTheme;
|
||||||
import org.thoughtcrime.securesms.util.LongClickCopySpan;
|
import org.thoughtcrime.securesms.util.LongClickCopySpan;
|
||||||
import org.thoughtcrime.securesms.util.LongClickMovementMethod;
|
import org.thoughtcrime.securesms.util.LongClickMovementMethod;
|
||||||
|
import org.thoughtcrime.securesms.util.SearchUtil;
|
||||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||||
import org.thoughtcrime.securesms.util.Util;
|
import org.thoughtcrime.securesms.util.Util;
|
||||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||||
@ -206,6 +210,7 @@ public class ConversationItem extends LinearLayout
|
|||||||
@NonNull Locale locale,
|
@NonNull Locale locale,
|
||||||
@NonNull Set<MessageRecord> batchSelected,
|
@NonNull Set<MessageRecord> batchSelected,
|
||||||
@NonNull Recipient conversationRecipient,
|
@NonNull Recipient conversationRecipient,
|
||||||
|
@Nullable String searchQuery,
|
||||||
boolean pulseHighlight)
|
boolean pulseHighlight)
|
||||||
{
|
{
|
||||||
this.messageRecord = messageRecord;
|
this.messageRecord = messageRecord;
|
||||||
@ -223,7 +228,7 @@ public class ConversationItem extends LinearLayout
|
|||||||
setMessageShape(messageRecord, previousMessageRecord, nextMessageRecord, groupThread);
|
setMessageShape(messageRecord, previousMessageRecord, nextMessageRecord, groupThread);
|
||||||
setMediaAttributes(messageRecord, previousMessageRecord, nextMessageRecord, conversationRecipient, groupThread);
|
setMediaAttributes(messageRecord, previousMessageRecord, nextMessageRecord, conversationRecipient, groupThread);
|
||||||
setInteractionState(messageRecord, pulseHighlight);
|
setInteractionState(messageRecord, pulseHighlight);
|
||||||
setBodyText(messageRecord);
|
setBodyText(messageRecord, searchQuery);
|
||||||
setBubbleState(messageRecord);
|
setBubbleState(messageRecord);
|
||||||
setStatusIcons(messageRecord);
|
setStatusIcons(messageRecord);
|
||||||
setContactPhoto(recipient);
|
setContactPhoto(recipient);
|
||||||
@ -401,7 +406,7 @@ public class ConversationItem extends LinearLayout
|
|||||||
return messageRecord.isMms() && !((MmsMessageRecord)messageRecord).getLinkPreviews().isEmpty();
|
return messageRecord.isMms() && !((MmsMessageRecord)messageRecord).getLinkPreviews().isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void setBodyText(MessageRecord messageRecord) {
|
private void setBodyText(MessageRecord messageRecord, @Nullable String searchQuery) {
|
||||||
bodyText.setClickable(false);
|
bodyText.setClickable(false);
|
||||||
bodyText.setFocusable(false);
|
bodyText.setFocusable(false);
|
||||||
bodyText.setTextSize(TypedValue.COMPLEX_UNIT_SP, TextSecurePreferences.getMessageBodyTextSize(context));
|
bodyText.setTextSize(TypedValue.COMPLEX_UNIT_SP, TextSecurePreferences.getMessageBodyTextSize(context));
|
||||||
@ -409,7 +414,11 @@ public class ConversationItem extends LinearLayout
|
|||||||
if (isCaptionlessMms(messageRecord)) {
|
if (isCaptionlessMms(messageRecord)) {
|
||||||
bodyText.setVisibility(View.GONE);
|
bodyText.setVisibility(View.GONE);
|
||||||
} else {
|
} 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);
|
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 Locale locale,
|
||||||
@NonNull Set<MessageRecord> batchSelected,
|
@NonNull Set<MessageRecord> batchSelected,
|
||||||
@NonNull Recipient conversationRecipient,
|
@NonNull Recipient conversationRecipient,
|
||||||
|
@Nullable String searchQuery,
|
||||||
boolean pulseUpdate)
|
boolean pulseUpdate)
|
||||||
{
|
{
|
||||||
this.batchSelected = batchSelected;
|
this.batchSelected = batchSelected;
|
||||||
|
@ -30,6 +30,8 @@ public class CursorList<T> implements List<T>, Closeable {
|
|||||||
public CursorList(@NonNull Cursor cursor, @NonNull ModelBuilder<T> modelBuilder) {
|
public CursorList(@NonNull Cursor cursor, @NonNull ModelBuilder<T> modelBuilder) {
|
||||||
this.cursor = cursor;
|
this.cursor = cursor;
|
||||||
this.modelBuilder = modelBuilder;
|
this.modelBuilder = modelBuilder;
|
||||||
|
|
||||||
|
forceQueryLoad();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static <T> CursorList<T> emptyList() {
|
public static <T> CursorList<T> emptyList() {
|
||||||
@ -195,6 +197,10 @@ public class CursorList<T> implements List<T>, Closeable {
|
|||||||
cursor.unregisterContentObserver(observer);
|
cursor.unregisterContentObserver(observer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void forceQueryLoad() {
|
||||||
|
cursor.getCount();
|
||||||
|
}
|
||||||
|
|
||||||
public interface ModelBuilder<T> {
|
public interface ModelBuilder<T> {
|
||||||
T build(@NonNull Cursor cursor);
|
T build(@NonNull Cursor cursor);
|
||||||
}
|
}
|
||||||
|
@ -124,6 +124,10 @@ public abstract class CursorRecyclerViewAdapter<VH extends RecyclerView.ViewHold
|
|||||||
+ (hasFooterView() ? 1 : 0);
|
+ (hasFooterView() ? 1 : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public int getCursorCount() {
|
||||||
|
return cursor.getCount();
|
||||||
|
}
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
@Override
|
@Override
|
||||||
public final void onViewRecycled(ViewHolder holder) {
|
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");
|
throw new IllegalStateException("this should only be called when the cursor is valid");
|
||||||
}
|
}
|
||||||
if (!cursor.moveToPosition(getCursorPosition(position))) {
|
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;
|
return cursor;
|
||||||
}
|
}
|
||||||
|
@ -183,6 +183,26 @@ public class MmsSmsDatabase extends Database {
|
|||||||
return -1;
|
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
|
* Retrieves the position of the message with the provided timestamp in the query results you'd
|
||||||
* get from calling {@link #getConversation(long)}.
|
* get from calling {@link #getConversation(long)}.
|
||||||
|
@ -23,76 +23,120 @@ public class SearchDatabase extends Database {
|
|||||||
|
|
||||||
public static final String ID = "rowid";
|
public static final String ID = "rowid";
|
||||||
public static final String BODY = MmsSmsColumns.BODY;
|
public static final String BODY = MmsSmsColumns.BODY;
|
||||||
public static final String RANK = "rank";
|
public static final String THREAD_ID = MmsSmsColumns.THREAD_ID;
|
||||||
public static final String SNIPPET = "snippet";
|
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 = {
|
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" +
|
"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",
|
"END;\n",
|
||||||
"CREATE TRIGGER sms_ad AFTER DELETE ON " + SmsDatabase.TABLE_NAME + " BEGIN\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",
|
"END;\n",
|
||||||
"CREATE TRIGGER sms_au AFTER UPDATE ON " + SmsDatabase.TABLE_NAME + " BEGIN\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 + "(" + 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 + ") 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;",
|
"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" +
|
"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",
|
"END;\n",
|
||||||
"CREATE TRIGGER mms_ad AFTER DELETE ON " + MmsDatabase.TABLE_NAME + " BEGIN\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",
|
"END;\n",
|
||||||
"CREATE TRIGGER mms_au AFTER UPDATE ON " + MmsDatabase.TABLE_NAME + " BEGIN\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 + "(" + 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 + ") 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;"
|
"END;"
|
||||||
};
|
};
|
||||||
|
|
||||||
private static final String MESSAGES_QUERY =
|
private static final String MESSAGES_QUERY =
|
||||||
"SELECT " +
|
"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 + ", " +
|
"snippet(" + SMS_FTS_TABLE_NAME + ", -1, '', '', '...', 7) AS " + SNIPPET + ", " +
|
||||||
SmsDatabase.TABLE_NAME + "." + SmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + ", " +
|
SmsDatabase.TABLE_NAME + "." + SmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + ", " +
|
||||||
MmsSmsColumns.THREAD_ID + " " +
|
SMS_FTS_TABLE_NAME + "." + THREAD_ID + " " +
|
||||||
"FROM " + SmsDatabase.TABLE_NAME + " " +
|
"FROM " + SmsDatabase.TABLE_NAME + " " +
|
||||||
"INNER JOIN " + SMS_FTS_TABLE_NAME + " ON " + SMS_FTS_TABLE_NAME + "." + ID + " = " + SmsDatabase.TABLE_NAME + "." + SmsDatabase.ID + " " +
|
"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 ? " +
|
"WHERE " + SMS_FTS_TABLE_NAME + " MATCH ? " +
|
||||||
"UNION ALL " +
|
"UNION ALL " +
|
||||||
"SELECT " +
|
"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 + ", " +
|
"snippet(" + MMS_FTS_TABLE_NAME + ", -1, '', '', '...', 7) AS " + SNIPPET + ", " +
|
||||||
MmsDatabase.TABLE_NAME + "." + MmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + ", " +
|
MmsDatabase.TABLE_NAME + "." + MmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + ", " +
|
||||||
MmsSmsColumns.THREAD_ID + " " +
|
MMS_FTS_TABLE_NAME + "." + THREAD_ID + " " +
|
||||||
"FROM " + MmsDatabase.TABLE_NAME + " " +
|
"FROM " + MmsDatabase.TABLE_NAME + " " +
|
||||||
"INNER JOIN " + MMS_FTS_TABLE_NAME + " ON " + MMS_FTS_TABLE_NAME + "." + ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " " +
|
"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 ? " +
|
"WHERE " + MMS_FTS_TABLE_NAME + " MATCH ? " +
|
||||||
"ORDER BY " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " DESC " +
|
"ORDER BY " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " DESC " +
|
||||||
"LIMIT 500";
|
"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) {
|
public SearchDatabase(@NonNull Context context, @NonNull SQLCipherOpenHelper databaseHelper) {
|
||||||
super(context, databaseHelper);
|
super(context, databaseHelper);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Cursor queryMessages(@NonNull String query) {
|
public Cursor queryMessages(@NonNull String query) {
|
||||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||||
|
String prefixQuery = adjustQuery(query);
|
||||||
List<String> tokens = Stream.of(query.split(" ")).filter(s -> s.trim().length() > 0).toList();
|
|
||||||
String prefixQuery = Util.join(tokens, "* ");
|
|
||||||
|
|
||||||
prefixQuery += "*";
|
|
||||||
|
|
||||||
Cursor cursor = db.rawQuery(MESSAGES_QUERY, new String[] { prefixQuery, prefixQuery });
|
Cursor cursor = db.rawQuery(MESSAGES_QUERY, new String[] { prefixQuery, prefixQuery });
|
||||||
setNotifyConverationListListeners(cursor);
|
setNotifyConverationListListeners(cursor);
|
||||||
return 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 = 14;
|
||||||
private static final int ATTACHMENT_CAPTIONS_FIX = 15;
|
private static final int ATTACHMENT_CAPTIONS_FIX = 15;
|
||||||
private static final int PREVIEWS = 16;
|
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 static final String DATABASE_NAME = "signal.db";
|
||||||
|
|
||||||
private final Context context;
|
private final Context context;
|
||||||
@ -201,9 +202,29 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (oldVersion < FULL_TEXT_SEARCH) {
|
if (oldVersion < FULL_TEXT_SEARCH) {
|
||||||
for (String sql : SearchDatabase.CREATE_TABLE) {
|
db.execSQL("CREATE VIRTUAL TABLE sms_fts USING fts5(body, content=sms, content_rowid=_id)");
|
||||||
db.execSQL(sql);
|
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.");
|
Log.i(TAG, "Beginning to build search index.");
|
||||||
long start = SystemClock.elapsedRealtime();
|
long start = SystemClock.elapsedRealtime();
|
||||||
@ -313,6 +334,55 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
|
|||||||
db.execSQL("ALTER TABLE mms ADD COLUMN previews TEXT");
|
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();
|
db.setTransactionSuccessful();
|
||||||
} finally {
|
} finally {
|
||||||
db.endTransaction();
|
db.endTransaction();
|
||||||
|
@ -172,7 +172,7 @@ public class SearchFragment extends Fragment implements SearchListAdapter.EventL
|
|||||||
ConversationListActivity conversationList = (ConversationListActivity) getActivity();
|
ConversationListActivity conversationList = (ConversationListActivity) getActivity();
|
||||||
if (conversationList != null) {
|
if (conversationList != null) {
|
||||||
conversationList.openConversation(message.threadId,
|
conversationList.openConversation(message.threadId,
|
||||||
message.recipient,
|
message.conversationRecipient,
|
||||||
ThreadDatabase.DistributionTypes.DEFAULT,
|
ThreadDatabase.DistributionTypes.DEFAULT,
|
||||||
-1,
|
-1,
|
||||||
startingPosition);
|
startingPosition);
|
||||||
|
@ -19,11 +19,14 @@ import org.thoughtcrime.securesms.database.MmsSmsColumns;
|
|||||||
import org.thoughtcrime.securesms.database.SearchDatabase;
|
import org.thoughtcrime.securesms.database.SearchDatabase;
|
||||||
import org.thoughtcrime.securesms.database.ThreadDatabase;
|
import org.thoughtcrime.securesms.database.ThreadDatabase;
|
||||||
import org.thoughtcrime.securesms.database.model.ThreadRecord;
|
import org.thoughtcrime.securesms.database.model.ThreadRecord;
|
||||||
|
import org.thoughtcrime.securesms.logging.Log;
|
||||||
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.model.MessageResult;
|
import org.thoughtcrime.securesms.search.model.MessageResult;
|
||||||
import org.thoughtcrime.securesms.search.model.SearchResult;
|
import org.thoughtcrime.securesms.search.model.SearchResult;
|
||||||
|
import org.thoughtcrime.securesms.util.Stopwatch;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
@ -32,7 +35,9 @@ import java.util.concurrent.Executor;
|
|||||||
/**
|
/**
|
||||||
* Manages data retrieval for search.
|
* 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<>();
|
private static final Set<Character> BANNED_CHARACTERS = new HashSet<>();
|
||||||
static {
|
static {
|
||||||
@ -58,7 +63,7 @@ class SearchRepository {
|
|||||||
private final ContactAccessor contactAccessor;
|
private final ContactAccessor contactAccessor;
|
||||||
private final Executor executor;
|
private final Executor executor;
|
||||||
|
|
||||||
SearchRepository(@NonNull Context context,
|
public SearchRepository(@NonNull Context context,
|
||||||
@NonNull SearchDatabase searchDatabase,
|
@NonNull SearchDatabase searchDatabase,
|
||||||
@NonNull ContactsDatabase contactsDatabase,
|
@NonNull ContactsDatabase contactsDatabase,
|
||||||
@NonNull ThreadDatabase threadDatabase,
|
@NonNull ThreadDatabase threadDatabase,
|
||||||
@ -73,22 +78,48 @@ class SearchRepository {
|
|||||||
this.executor = executor;
|
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)) {
|
if (TextUtils.isEmpty(query)) {
|
||||||
callback.onResult(SearchResult.EMPTY);
|
callback.onResult(SearchResult.EMPTY);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
executor.execute(() -> {
|
executor.execute(() -> {
|
||||||
|
Stopwatch timer = new Stopwatch("FtsQuery");
|
||||||
|
|
||||||
String cleanQuery = sanitizeQuery(query);
|
String cleanQuery = sanitizeQuery(query);
|
||||||
|
timer.split("clean");
|
||||||
|
|
||||||
CursorList<Recipient> contacts = queryContacts(cleanQuery);
|
CursorList<Recipient> contacts = queryContacts(cleanQuery);
|
||||||
|
timer.split("contacts");
|
||||||
|
|
||||||
CursorList<ThreadRecord> conversations = queryConversations(cleanQuery);
|
CursorList<ThreadRecord> conversations = queryConversations(cleanQuery);
|
||||||
|
timer.split("conversations");
|
||||||
|
|
||||||
CursorList<MessageResult> messages = queryMessages(cleanQuery);
|
CursorList<MessageResult> messages = queryMessages(cleanQuery);
|
||||||
|
timer.split("messages");
|
||||||
|
|
||||||
|
timer.stop(TAG);
|
||||||
|
|
||||||
callback.onResult(new SearchResult(cleanQuery, contacts, conversations, messages));
|
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) {
|
private CursorList<Recipient> queryContacts(String query) {
|
||||||
if (!Permissions.hasAny(context, Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)) {
|
if (!Permissions.hasAny(context, Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)) {
|
||||||
return CursorList.emptyList();
|
return CursorList.emptyList();
|
||||||
@ -116,6 +147,12 @@ class SearchRepository {
|
|||||||
: CursorList.emptyList();
|
: 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.
|
* 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.
|
* MATCH queries have a separate format of their own that disallow most "special" characters.
|
||||||
@ -177,17 +214,19 @@ class SearchRepository {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public MessageResult build(@NonNull Cursor cursor) {
|
public MessageResult build(@NonNull Cursor cursor) {
|
||||||
Address address = Address.fromSerialized(cursor.getString(0));
|
Address conversationAddress = Address.fromSerialized(cursor.getString(cursor.getColumnIndex(SearchDatabase.CONVERSATION_ADDRESS)));
|
||||||
Recipient recipient = Recipient.from(context, address, false);
|
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));
|
String body = cursor.getString(cursor.getColumnIndexOrThrow(SearchDatabase.SNIPPET));
|
||||||
long receivedMs = cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.NORMALIZED_DATE_RECEIVED));
|
long receivedMs = cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.NORMALIZED_DATE_RECEIVED));
|
||||||
long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.THREAD_ID));
|
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 {
|
public interface Callback<E> {
|
||||||
void onResult(@NonNull SearchResult result);
|
void onResult(@NonNull E result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -11,6 +11,7 @@ import android.text.TextUtils;
|
|||||||
|
|
||||||
import org.thoughtcrime.securesms.search.model.SearchResult;
|
import org.thoughtcrime.securesms.search.model.SearchResult;
|
||||||
import org.thoughtcrime.securesms.util.Debouncer;
|
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
|
* 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;
|
private String lastQuery;
|
||||||
|
|
||||||
SearchViewModel(@NonNull SearchRepository searchRepository) {
|
private SearchViewModel(@NonNull SearchRepository searchRepository) {
|
||||||
this.searchResult = new ObservingLiveData();
|
this.searchResult = new ObservingLiveData();
|
||||||
this.searchRepository = searchRepository;
|
this.searchRepository = searchRepository;
|
||||||
this.debouncer = new Debouncer(500);
|
this.debouncer = new Debouncer(500);
|
||||||
@ -49,7 +50,15 @@ class SearchViewModel extends ViewModel {
|
|||||||
|
|
||||||
void updateQuery(String query) {
|
void updateQuery(String query) {
|
||||||
lastQuery = 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
|
@NonNull
|
||||||
|
@ -9,17 +9,20 @@ import org.thoughtcrime.securesms.recipients.Recipient;
|
|||||||
*/
|
*/
|
||||||
public class MessageResult {
|
public class MessageResult {
|
||||||
|
|
||||||
public final Recipient recipient;
|
public final Recipient conversationRecipient;
|
||||||
|
public final Recipient messageRecipient;
|
||||||
public final String bodySnippet;
|
public final String bodySnippet;
|
||||||
public final long threadId;
|
public final long threadId;
|
||||||
public final long receivedTimestampMs;
|
public final long receivedTimestampMs;
|
||||||
|
|
||||||
public MessageResult(@NonNull Recipient recipient,
|
public MessageResult(@NonNull Recipient conversationRecipient,
|
||||||
|
@NonNull Recipient messageRecipient,
|
||||||
@NonNull String bodySnippet,
|
@NonNull String bodySnippet,
|
||||||
long threadId,
|
long threadId,
|
||||||
long receivedTimestampMs)
|
long receivedTimestampMs)
|
||||||
{
|
{
|
||||||
this.recipient = recipient;
|
this.conversationRecipient = conversationRecipient;
|
||||||
|
this.messageRecipient = messageRecipient;
|
||||||
this.bodySnippet = bodySnippet;
|
this.bodySnippet = bodySnippet;
|
||||||
this.threadId = threadId;
|
this.threadId = threadId;
|
||||||
this.receivedTimestampMs = receivedTimestampMs;
|
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