package org.thoughtcrime.securesms.search; import android.Manifest; import android.content.Context; import android.database.Cursor; import android.database.DatabaseUtils; import android.database.MergeCursor; import android.support.annotation.NonNull; import android.text.TextUtils; import com.annimon.stream.Stream; import org.thoughtcrime.securesms.contacts.ContactAccessor; import org.thoughtcrime.securesms.contacts.ContactsDatabase; import org.thoughtcrime.securesms.database.Address; import org.thoughtcrime.securesms.database.CursorList; import org.thoughtcrime.securesms.database.MmsSmsColumns; import org.thoughtcrime.securesms.database.SearchDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.database.model.ThreadRecord; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.search.model.MessageResult; import org.thoughtcrime.securesms.search.model.SearchResult; import org.thoughtcrime.securesms.util.Stopwatch; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.concurrent.Executor; /** * Manages data retrieval for search. */ public class SearchRepository { private static final String TAG = SearchRepository.class.getSimpleName(); private static final Set BANNED_CHARACTERS = new HashSet<>(); static { // Several ranges of invalid ASCII characters for (int i = 33; i <= 47; i++) { BANNED_CHARACTERS.add((char) i); } for (int i = 58; i <= 64; i++) { BANNED_CHARACTERS.add((char) i); } for (int i = 91; i <= 96; i++) { BANNED_CHARACTERS.add((char) i); } for (int i = 123; i <= 126; i++) { BANNED_CHARACTERS.add((char) i); } } private final Context context; private final SearchDatabase searchDatabase; private final ContactsDatabase contactsDatabase; private final ThreadDatabase threadDatabase; private final ContactAccessor contactAccessor; private final Executor executor; public SearchRepository(@NonNull Context context, @NonNull SearchDatabase searchDatabase, @NonNull ContactsDatabase contactsDatabase, @NonNull ThreadDatabase threadDatabase, @NonNull ContactAccessor contactAccessor, @NonNull Executor executor) { this.context = context.getApplicationContext(); this.searchDatabase = searchDatabase; this.contactsDatabase = contactsDatabase; this.threadDatabase = threadDatabase; this.contactAccessor = contactAccessor; this.executor = executor; } public void query(@NonNull String query, @NonNull Callback callback) { if (TextUtils.isEmpty(query)) { callback.onResult(SearchResult.EMPTY); return; } executor.execute(() -> { Stopwatch timer = new Stopwatch("FtsQuery"); String cleanQuery = sanitizeQuery(query); timer.split("clean"); CursorList contacts = queryContacts(cleanQuery); timer.split("contacts"); CursorList conversations = queryConversations(cleanQuery); timer.split("conversations"); CursorList messages = queryMessages(cleanQuery); timer.split("messages"); timer.stop(TAG); callback.onResult(new SearchResult(cleanQuery, contacts, conversations, messages)); }); } public void query(@NonNull String query, long threadId, @NonNull Callback> callback) { if (TextUtils.isEmpty(query)) { callback.onResult(CursorList.emptyList()); return; } executor.execute(() -> { long startTime = System.currentTimeMillis(); CursorList messages = queryMessages(sanitizeQuery(query), threadId); Log.d(TAG, "[ConversationQuery] " + (System.currentTimeMillis() - startTime) + " ms"); callback.onResult(messages); }); } private CursorList queryContacts(String query) { if (!Permissions.hasAny(context, Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)) { return CursorList.emptyList(); } Cursor textSecureContacts = contactsDatabase.queryTextSecureContacts(query); Cursor systemContacts = contactsDatabase.querySystemContacts(query); MergeCursor contacts = new MergeCursor(new Cursor[]{ textSecureContacts, systemContacts }); return new CursorList<>(contacts, new RecipientModelBuilder(context)); } private CursorList queryConversations(@NonNull String query) { List numbers = contactAccessor.getNumbersForThreadSearchFilter(context, query); List
addresses = Stream.of(numbers).map(number -> Address.fromExternal(context, number)).toList(); Cursor conversations = threadDatabase.getFilteredConversationList(addresses); return conversations != null ? new CursorList<>(conversations, new ThreadModelBuilder(threadDatabase)) : CursorList.emptyList(); } private CursorList queryMessages(@NonNull String query) { Cursor messages = searchDatabase.queryMessages(query); return messages != null ? new CursorList<>(messages, new MessageModelBuilder(context)) : CursorList.emptyList(); } private CursorList queryMessages(@NonNull String query, long threadId) { Cursor messages = searchDatabase.queryMessages(query, threadId); return messages != null ? new CursorList<>(messages, new MessageModelBuilder(context)) : CursorList.emptyList(); } /** * Unfortunately {@link DatabaseUtils#sqlEscapeString(String)} is not sufficient for our purposes. * MATCH queries have a separate format of their own that disallow most "special" characters. * * Also, SQLite can't search for apostrophes, meaning we can't normally find words like "I'm". * However, if we replace the apostrophe with a space, then the query will find the match. */ private String sanitizeQuery(@NonNull String query) { StringBuilder out = new StringBuilder(); for (int i = 0; i < query.length(); i++) { char c = query.charAt(i); if (!BANNED_CHARACTERS.contains(c)) { out.append(c); } else if (c == '\'') { out.append(' '); } } return out.toString(); } private static class RecipientModelBuilder implements CursorList.ModelBuilder { private final Context context; RecipientModelBuilder(@NonNull Context context) { this.context = context; } @Override public Recipient build(@NonNull Cursor cursor) { Address address = Address.fromExternal(context, cursor.getString(1)); return Recipient.from(context, address, false); } } private static class ThreadModelBuilder implements CursorList.ModelBuilder { private final ThreadDatabase threadDatabase; ThreadModelBuilder(@NonNull ThreadDatabase threadDatabase) { this.threadDatabase = threadDatabase; } @Override public ThreadRecord build(@NonNull Cursor cursor) { return threadDatabase.readerFor(cursor).getCurrent(); } } private static class MessageModelBuilder implements CursorList.ModelBuilder { private final Context context; MessageModelBuilder(@NonNull Context context) { this.context = context; } @Override public MessageResult build(@NonNull Cursor cursor) { Address conversationAddress = Address.fromSerialized(cursor.getString(cursor.getColumnIndex(SearchDatabase.CONVERSATION_ADDRESS))); Address messageAddress = Address.fromSerialized(cursor.getString(cursor.getColumnIndexOrThrow(SearchDatabase.MESSAGE_ADDRESS))); Recipient conversationRecipient = Recipient.from(context, conversationAddress, false); Recipient messageRecipient = Recipient.from(context, messageAddress, false); String body = cursor.getString(cursor.getColumnIndexOrThrow(SearchDatabase.SNIPPET)); long receivedMs = cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.NORMALIZED_DATE_RECEIVED)); long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.THREAD_ID)); return new MessageResult(conversationRecipient, messageRecipient, body, threadId, receivedMs); } } public interface Callback { void onResult(@NonNull E result); } }