search term parsing + highlighting

Daniel Gultsch created

Change summary

src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java            | 10 
src/main/java/eu/siacs/conversations/services/MessageSearchTask.java             |  6 
src/main/java/eu/siacs/conversations/services/XmppConnectionService.java         |  2 
src/main/java/eu/siacs/conversations/ui/SearchActivity.java                      | 16 
src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java              | 11 
src/main/java/eu/siacs/conversations/ui/interfaces/OnSearchResultsAvailable.java |  2 
src/main/java/eu/siacs/conversations/utils/FtsUtils.java                         | 94 
src/main/java/eu/siacs/conversations/utils/StylingHelper.java                    | 10 
8 files changed, 128 insertions(+), 23 deletions(-)

Detailed changes

src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java 🔗

@@ -50,6 +50,7 @@ import eu.siacs.conversations.entities.Roster;
 import eu.siacs.conversations.entities.ServiceDiscoveryResult;
 import eu.siacs.conversations.services.ShortcutService;
 import eu.siacs.conversations.utils.CryptoHelper;
+import eu.siacs.conversations.utils.FtsUtils;
 import eu.siacs.conversations.utils.MimeUtils;
 import eu.siacs.conversations.utils.Resolver;
 import eu.siacs.conversations.xmpp.mam.MamReference;
@@ -229,6 +230,10 @@ public class DatabaseBackend extends SQLiteOpenHelper {
 		db.execSQL(CREATE_IDENTITIES_STATEMENT);
 		db.execSQL(CREATE_PRESENCE_TEMPLATES_STATEMENT);
 		db.execSQL(CREATE_RESOLVER_RESULTS_TABLE);
+		db.execSQL(CREATE_MESSAGE_INDEX_TABLE);
+		db.execSQL(CREATE_MESSAGE_INSERT_TRIGGER);
+		db.execSQL(CREATE_MESSAGE_UPDATE_TRIGGER);
+		db.execSQL(CREATE_MESSAGE_DELETE_TRIGGER);
 	}
 
 	@Override
@@ -718,10 +723,11 @@ public class DatabaseBackend extends SQLiteOpenHelper {
 		return list;
 	}
 
-	public Cursor getMessageSearchCursor(String term) {
+	public Cursor getMessageSearchCursor(List<String> term) {
 		SQLiteDatabase db = this.getReadableDatabase();
 		String SQL = "SELECT "+Message.TABLENAME+".*,"+Conversation.TABLENAME+'.'+Conversation.CONTACTJID+','+Conversation.TABLENAME+'.'+Conversation.ACCOUNT+','+Conversation.TABLENAME+'.'+Conversation.MODE+" FROM "+Message.TABLENAME +" join "+Conversation.TABLENAME+" on "+Message.TABLENAME+'.'+Message.CONVERSATION+'='+Conversation.TABLENAME+'.'+Conversation.UUID+" join messages_index ON messages_index.uuid=messages.uuid where "+Message.ENCRYPTION+" NOT IN("+Message.ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE+','+Message.ENCRYPTION_PGP+','+Message.ENCRYPTION_DECRYPTION_FAILED+") AND messages_index.body MATCH ? ORDER BY "+Message.TIME_SENT+" DESC limit "+Config.MAX_SEARCH_RESULTS;
-		return db.rawQuery(SQL,new String[]{'%'+term+'%'});
+		Log.d(Config.LOGTAG,"search term: "+FtsUtils.toMatchString(term));
+		return db.rawQuery(SQL,new String[]{FtsUtils.toMatchString(term)});
 	}
 
 	public Iterable<Message> getMessagesIterable(final Conversation conversation) {

src/main/java/eu/siacs/conversations/services/MessageSearchTask.java 🔗

@@ -55,18 +55,18 @@ public class MessageSearchTask implements Runnable, Cancellable {
 	private static final ReplacingSerialSingleThreadExecutor EXECUTOR = new ReplacingSerialSingleThreadExecutor(MessageSearchTask.class.getName());
 
 	private final XmppConnectionService xmppConnectionService;
-	private final String term;
+	private final List<String> term;
 	private final OnSearchResultsAvailable onSearchResultsAvailable;
 
 	private boolean isCancelled = false;
 
-	private MessageSearchTask(XmppConnectionService xmppConnectionService, String term, OnSearchResultsAvailable onSearchResultsAvailable) {
+	private MessageSearchTask(XmppConnectionService xmppConnectionService, List<String> term, OnSearchResultsAvailable onSearchResultsAvailable) {
 		this.xmppConnectionService = xmppConnectionService;
 		this.term = term;
 		this.onSearchResultsAvailable = onSearchResultsAvailable;
 	}
 
-	public static void search(XmppConnectionService xmppConnectionService, String term, OnSearchResultsAvailable onSearchResultsAvailable) {
+	public static void search(XmppConnectionService xmppConnectionService, List<String> term, OnSearchResultsAvailable onSearchResultsAvailable) {
 		new MessageSearchTask(xmppConnectionService, term, onSearchResultsAvailable).executeInBackground();
 	}
 

src/main/java/eu/siacs/conversations/services/XmppConnectionService.java 🔗

@@ -535,7 +535,7 @@ public class XmppConnectionService extends Service {
 		return find(getConversations(), account, jid);
 	}
 
-	public void search(String term, OnSearchResultsAvailable onSearchResultsAvailable) {
+	public void search(List<String> term, OnSearchResultsAvailable onSearchResultsAvailable) {
 		MessageSearchTask.search(this, term, onSearchResultsAvailable);
 	}
 

src/main/java/eu/siacs/conversations/ui/SearchActivity.java 🔗

@@ -64,6 +64,7 @@ import eu.siacs.conversations.ui.util.DateSeparator;
 import eu.siacs.conversations.ui.util.Drawable;
 import eu.siacs.conversations.ui.util.ListViewUtils;
 import eu.siacs.conversations.ui.util.ShareUtil;
+import eu.siacs.conversations.utils.FtsUtils;
 import eu.siacs.conversations.utils.MessageUtils;
 
 import static eu.siacs.conversations.ui.util.SoftKeyboardUtils.hideSoftKeyboard;
@@ -75,7 +76,7 @@ public class SearchActivity extends XmppActivity implements TextWatcher, OnSearc
 	private MessageAdapter messageListAdapter;
 	private final List<Message> messages = new ArrayList<>();
 	private WeakReference<Message> selectedMessageReference = new WeakReference<>(null);
-	private final ChangeWatcher<String> currentSearch = new ChangeWatcher<>();
+	private final ChangeWatcher<List<String>> currentSearch = new ChangeWatcher<>();
 
 	@Override
 	public void onCreate(final Bundle savedInstanceState) {
@@ -153,13 +154,10 @@ public class SearchActivity extends XmppActivity implements TextWatcher, OnSearc
 	}
 
 	private void quote(Message message) {
-		String text = MessageUtils.prepareQuote(message);
-		final Conversational conversational = message.getConversation();
-		switchToConversationAndQuote(wrap(message.getConversation()), text);
+		switchToConversationAndQuote(wrap(message.getConversation()), MessageUtils.prepareQuote(message));
 	}
 
 	private Conversation wrap(Conversational conversational) {
-		final Conversation conversation;
 		if (conversational instanceof Conversation) {
 			return (Conversation) conversational;
 		} else {
@@ -205,12 +203,12 @@ public class SearchActivity extends XmppActivity implements TextWatcher, OnSearc
 
 	@Override
 	public void afterTextChanged(Editable s) {
-		final String term = s.toString().trim();
+		final List<String> term = FtsUtils.parse(s.toString().trim());
 		if (!currentSearch.watch(term)) {
 			return;
 		}
-		if (term.length() > 0) {
-			xmppConnectionService.search(s.toString().trim(), this);
+		if (term.size() > 0) {
+			xmppConnectionService.search(term, this);
 		} else {
 			MessageSearchTask.cancelRunningTasks();
 			this.messages.clear();
@@ -221,7 +219,7 @@ public class SearchActivity extends XmppActivity implements TextWatcher, OnSearc
 	}
 
 	@Override
-	public void onSearchResultsAvailable(String term, List<Message> messages) {
+	public void onSearchResultsAvailable(List<String> term, List<Message> messages) {
 		runOnUiThread(() -> {
 			this.messages.clear();
 			messageListAdapter.setHighlightedTerm(term);

src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java 🔗

@@ -34,7 +34,6 @@ import android.view.ActionMode;
 import android.view.Menu;
 import android.view.MenuItem;
 import android.view.View;
-import android.view.View.OnClickListener;
 import android.view.ViewGroup;
 import android.view.WindowManager;
 import android.widget.ArrayAdapter;
@@ -99,7 +98,7 @@ public class MessageAdapter extends ArrayAdapter<Message> implements CopyTextVie
 					+ "\\;\\/\\?\\@\\&\\=\\#\\~\\-\\.\\+\\!\\*\\'\\(\\)\\,\\_])"
 					+ "|(?:\\%[a-fA-F0-9]{2}))+");
 
-	private String highlightedText = null;
+	private List<String> highlightedTerm = null;
 
 	private static final Linkify.TransformFilter WEBURL_TRANSFORM_FILTER = (matcher, url) -> {
 		if (url == null) {
@@ -550,8 +549,8 @@ public class MessageAdapter extends ArrayAdapter<Message> implements CopyTextVie
 			}
 
 			StylingHelper.format(body, viewHolder.messageBody.getCurrentTextColor());
-			if (highlightedText != null) {
-				StylingHelper.highlight(activity, body, highlightedText, StylingHelper.isDarkText(viewHolder.messageBody));
+			if (highlightedTerm != null) {
+				StylingHelper.highlight(activity, body, highlightedTerm, StylingHelper.isDarkText(viewHolder.messageBody));
 			}
 
 			Linkify.addLinks(body, XMPP_PATTERN, "xmpp", XMPPURI_MATCH_FILTER, null);
@@ -1008,8 +1007,8 @@ public class MessageAdapter extends ArrayAdapter<Message> implements CopyTextVie
 		}
 	}
 
-	public void setHighlightedTerm(String term) {
-		this.highlightedText = term;
+	public void setHighlightedTerm(List<String> term) {
+		this.highlightedTerm = term;
 	}
 
 	public interface OnQuoteListener {

src/main/java/eu/siacs/conversations/utils/FtsUtils.java 🔗

@@ -0,0 +1,94 @@
+/*
+ * Copyright (c) 2018, Daniel Gultsch All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without modification,
+ * are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation and/or
+ * other materials provided with the distribution.
+ *
+ * 3. Neither the name of the copyright holder nor the names of its contributors
+ * may be used to endorse or promote products derived from this software without
+ * specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package eu.siacs.conversations.utils;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Locale;
+
+public class FtsUtils {
+
+	private static List<String> KEYWORDS = Arrays.asList("OR", "AND");
+
+	public static List<String> parse(String input) {
+		List<String> term = new ArrayList<>();
+		for (String part : input.split("\\s+")) {
+			if (part.isEmpty()) {
+				continue;
+			}
+			final String cleaned = part.substring(getStartIndex(part), getEndIndex(part) +1);
+			if (isKeyword(cleaned)) {
+				term.add(part);
+			} else {
+				term.add(cleaned);
+			}
+		}
+		return term;
+	}
+
+	public static String toMatchString(List<String> terms) {
+		StringBuilder builder = new StringBuilder();
+		for (String term : terms) {
+			if (builder.length() != 0) {
+				builder.append(' ');
+			}
+			if (isKeyword(term)) {
+				builder.append(term.toUpperCase(Locale.ENGLISH));
+			} else if (term.contains("*") || term.startsWith("-")) {
+				builder.append(term);
+			} else {
+				builder.append('*').append(term).append('*');
+			}
+		}
+		return builder.toString();
+	}
+
+	public static boolean isKeyword(String term) {
+		return KEYWORDS.contains(term.toUpperCase(Locale.ENGLISH));
+	}
+
+	private static int getStartIndex(String term) {
+		int index = 0;
+		while (term.charAt(index) == '*') {
+			++index;
+		}
+		return index;
+	}
+
+	private static int getEndIndex(String term) {
+		int index = term.length() - 1;
+		while (term.charAt(index) == '*') {
+			--index;
+		}
+		return index;
+	}
+
+}

src/main/java/eu/siacs/conversations/utils/StylingHelper.java 🔗

@@ -91,7 +91,15 @@ public class StylingHelper {
 		format(editable, end, editable.length() - 1, textColor);
 	}
 
-	public static void highlight(final Context context, final Editable editable, String needle, boolean dark) {
+	public static void highlight(final Context context, final Editable editable, List<String> needles, boolean dark) {
+		for(String needle : needles) {
+			if (!FtsUtils.isKeyword(needle)) {
+				highlight(context, editable, needle, dark);
+			}
+		}
+	}
+
+	private static void highlight(final Context context, final Editable editable, String needle, boolean dark) {
 		final int length = needle.length();
 		String string = editable.toString();
 		int start = indexOfIgnoreCase(string, needle, 0);