Add a global search (#834)

* feat: modifying search functionalities to include contacts

* feat: add global search UI input layouts and color attributes

* feat: add global search repository and model content

* feat: adding diff callbacks and wiring up global search vm to views

* feat: adding scroll to message, figuring out new query for recipient thread search

* feat: messing with the search and highlighting functionality after wiring up bindings

* fix: compile error from merge

* fix: gradlew build errors

* feat: filtering contacts by existing un-archived threads

* refactor: prevent note to self breaking, update queries and logic in search repo to include member->group reverse searches

* feat: adding home screen new redesigns for search

* feat: replacing designs and adding new group subtitle text

* feat: small design improvements and incrementing gradle build number to install on device

* feat: add scrollbars for search

* feat: replace isVisible for cancel button now that GlobalSearchInputLayout.kt replaces header

* refactor: all queries are debounced not just all but 2 char

* refactor: remove visibility modifiers for cancel icon

* refactor: use simplified non-db and context related models in display, remove db get group members call from binding data

* fix: use threadId instead of group's address

* refactor: better close on cancel, removing only yourself from group member list in open groups

* refactor: seed view back to inflated on create and visibility for empty placeholder and seed view text

* refactor: fixing build issues and new designs for message list

* refactor: use dynamic limit

* refactor: include raw session ID string search for non-empty threads

* fix: build lint errors

* fix: build issues

* feat: add in path to the settings activity

* refactor: remove wildcard imports
This commit is contained in:
Harris 2022-02-07 17:06:27 +11:00 committed by GitHub
parent 561ce83aa4
commit dd1da6b1a4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
84 changed files with 1370 additions and 376 deletions

View File

@ -130,6 +130,7 @@ dependencies {
testImplementation 'androidx.test:core:1.3.0' testImplementation 'androidx.test:core:1.3.0'
testImplementation "androidx.arch.core:core-testing:2.1.0" testImplementation "androidx.arch.core:core-testing:2.1.0"
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion" testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"
androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"
// Core library // Core library
androidTestImplementation 'androidx.test:core:1.4.0' androidTestImplementation 'androidx.test:core:1.4.0'
@ -156,7 +157,7 @@ dependencies {
testImplementation 'org.robolectric:shadows-multidex:4.4' testImplementation 'org.robolectric:shadows-multidex:4.4'
} }
def canonicalVersionCode = 242 def canonicalVersionCode = 248
def canonicalVersionName = "1.11.14" def canonicalVersionName = "1.11.14"
def postFixSize = 10 def postFixSize = 10

View File

@ -1,6 +1,6 @@
<manifest <manifest
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
package="network.loki.messenger"> package="network.loki.messenger.test">
<application> <application>
<uses-library android:name="android.test.runner" <uses-library android:name="android.test.runner"
android:required="false" /> android:required="false" />

View File

@ -15,6 +15,7 @@ import androidx.test.platform.app.InstrumentationRegistry
import network.loki.messenger.util.InputBarButtonDrawableMatcher.Companion.inputButtonWithDrawable import network.loki.messenger.util.InputBarButtonDrawableMatcher.Companion.inputButtonWithDrawable
import network.loki.messenger.util.NewConversationButtonDrawableMatcher.Companion.newConversationButtonWithDrawable import network.loki.messenger.util.NewConversationButtonDrawableMatcher.Companion.newConversationButtonWithDrawable
import org.hamcrest.Matchers.allOf import org.hamcrest.Matchers.allOf
import org.hamcrest.Matchers.not
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@ -73,7 +74,7 @@ class HomeActivityTests {
onView(allOf(withId(R.id.button), isDescendantOfA(withId(R.id.seedReminderView)))).perform(ViewActions.click()) onView(allOf(withId(R.id.button), isDescendantOfA(withId(R.id.seedReminderView)))).perform(ViewActions.click())
onView(withId(R.id.copyButton)).perform(ViewActions.click()) onView(withId(R.id.copyButton)).perform(ViewActions.click())
pressBack() pressBack()
onView(withId(R.id.seedReminderView)).check(matches(withEffectiveVisibility(Visibility.GONE))) onView(withId(R.id.seedReminderView)).check(matches(not(isDisplayed())))
} }
@Test @Test
@ -85,7 +86,7 @@ class HomeActivityTests {
@Test @Test
fun testIsVisible_alreadyDismissed_seedView() { fun testIsVisible_alreadyDismissed_seedView() {
setupLoggedInState(hasViewedSeed = true) setupLoggedInState(hasViewedSeed = true)
onView(withId(R.id.seedReminderView)).check(doesNotExist()) onView(withId(R.id.seedReminderView)).check(matches(not(isDisplayed())))
} }
@Test @Test

View File

@ -35,12 +35,10 @@ public class AudioCodec {
public AudioCodec() throws IOException { public AudioCodec() throws IOException {
this.bufferSize = AudioRecord.getMinBufferSize(SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT); this.bufferSize = AudioRecord.getMinBufferSize(SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT);
this.audioRecord = createAudioRecord(this.bufferSize);
this.mediaCodec = createMediaCodec(this.bufferSize); this.mediaCodec = createMediaCodec(this.bufferSize);
this.mediaCodec.start();
try { try {
this.audioRecord = createAudioRecord(this.bufferSize);
this.mediaCodec.start();
audioRecord.startRecording(); audioRecord.startRecording();
} catch (Exception e) { } catch (Exception e) {
Log.w(TAG, e); Log.w(TAG, e);
@ -167,7 +165,7 @@ public class AudioCodec {
return adtsHeader; return adtsHeader;
} }
private AudioRecord createAudioRecord(int bufferSize) { private AudioRecord createAudioRecord(int bufferSize) throws SecurityException {
return new AudioRecord(MediaRecorder.AudioSource.MIC, SAMPLE_RATE, return new AudioRecord(MediaRecorder.AudioSource.MIC, SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO, AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT, bufferSize * 10); AudioFormat.ENCODING_PCM_16BIT, bufferSize * 10);

View File

@ -66,25 +66,18 @@ public class ContactAccessor {
public List<String> getNumbersForThreadSearchFilter(Context context, String constraint) { public List<String> getNumbersForThreadSearchFilter(Context context, String constraint) {
LinkedList<String> numberList = new LinkedList<>(); LinkedList<String> numberList = new LinkedList<>();
GroupDatabase.Reader reader = null;
GroupRecord record; GroupRecord record;
try (GroupDatabase.Reader reader = DatabaseComponent.get(context).groupDatabase().getGroupsFilteredByTitle(constraint)) {
try {
reader = DatabaseComponent.get(context).groupDatabase().getGroupsFilteredByTitle(constraint);
while ((record = reader.getNext()) != null) { while ((record = reader.getNext()) != null) {
numberList.add(record.getEncodedId()); numberList.add(record.getEncodedId());
} }
} finally {
if (reader != null)
reader.close();
} }
if (context.getString(R.string.note_to_self).toLowerCase().contains(constraint.toLowerCase()) && // if (context.getString(R.string.note_to_self).toLowerCase().contains(constraint.toLowerCase()) &&
!numberList.contains(TextSecurePreferences.getLocalNumber(context))) // !numberList.contains(TextSecurePreferences.getLocalNumber(context)))
{ // {
numberList.add(TextSecurePreferences.getLocalNumber(context)); // numberList.add(TextSecurePreferences.getLocalNumber(context));
} // }
return numberList; return numberList;
} }

View File

@ -133,6 +133,8 @@ import org.thoughtcrime.securesms.util.push
import org.thoughtcrime.securesms.util.toPx import org.thoughtcrime.securesms.util.toPx
import java.util.Locale import java.util.Locale
import java.util.concurrent.ExecutionException import java.util.concurrent.ExecutionException
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicReference
import javax.inject.Inject import javax.inject.Inject
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.max import kotlin.math.max
@ -249,12 +251,16 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
private val documentButton by lazy { InputBarButton(this, R.drawable.ic_document_small_dark, hasOpaqueBackground = true) } private val documentButton by lazy { InputBarButton(this, R.drawable.ic_document_small_dark, hasOpaqueBackground = true) }
private val libraryButton by lazy { InputBarButton(this, R.drawable.ic_baseline_photo_library_24, hasOpaqueBackground = true) } private val libraryButton by lazy { InputBarButton(this, R.drawable.ic_baseline_photo_library_24, hasOpaqueBackground = true) }
private val cameraButton by lazy { InputBarButton(this, R.drawable.ic_baseline_photo_camera_24, hasOpaqueBackground = true) } private val cameraButton by lazy { InputBarButton(this, R.drawable.ic_baseline_photo_camera_24, hasOpaqueBackground = true) }
private val messageToScrollTimestamp = AtomicLong(-1)
private val messageToScrollAuthor = AtomicReference<Address?>(null)
// region Settings // region Settings
companion object { companion object {
// Extras // Extras
const val THREAD_ID = "thread_id" const val THREAD_ID = "thread_id"
const val ADDRESS = "address" const val ADDRESS = "address"
const val SCROLL_MESSAGE_ID = "scroll_message_id"
const val SCROLL_MESSAGE_AUTHOR = "scroll_message_author"
// Request codes // Request codes
const val PICK_DOCUMENT = 2 const val PICK_DOCUMENT = 2
const val TAKE_PHOTO = 7 const val TAKE_PHOTO = 7
@ -272,6 +278,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
super.onCreate(savedInstanceState, isReady) super.onCreate(savedInstanceState, isReady)
binding = ActivityConversationV2Binding.inflate(layoutInflater) binding = ActivityConversationV2Binding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
// messageIdToScroll
messageToScrollTimestamp.set(intent.getLongExtra(SCROLL_MESSAGE_ID, -1))
messageToScrollAuthor.set(intent.getParcelableExtra(SCROLL_MESSAGE_AUTHOR))
val thread = threadDb.getRecipientForThreadId(viewModel.threadId) val thread = threadDb.getRecipientForThreadId(viewModel.threadId)
if (thread == null) { if (thread == null) {
Toast.makeText(this, "This thread has been deleted.", Toast.LENGTH_LONG).show() Toast.makeText(this, "This thread has been deleted.", Toast.LENGTH_LONG).show()
@ -351,6 +360,13 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
override fun onLoadFinished(loader: Loader<Cursor>, cursor: Cursor?) { override fun onLoadFinished(loader: Loader<Cursor>, cursor: Cursor?) {
adapter.changeCursor(cursor) adapter.changeCursor(cursor)
if (cursor != null) {
val messageTimestamp = messageToScrollTimestamp.getAndSet(-1)
val author = messageToScrollAuthor.getAndSet(null)
if (author != null && messageTimestamp >= 0) {
jumpToMessage(author, messageTimestamp, null)
}
}
} }
override fun onLoaderReset(cursor: Loader<Cursor>) { override fun onLoaderReset(cursor: Loader<Cursor>) {
@ -1296,7 +1312,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
override fun resendMessage(messages: Set<MessageRecord>) { override fun resendMessage(messages: Set<MessageRecord>) {
messages.forEach { messageRecord -> messages.iterator().forEach { messageRecord ->
ResendMessageUtilities.resend(messageRecord) ResendMessageUtilities.resend(messageRecord)
} }
endActionMode() endActionMode()

View File

@ -90,7 +90,7 @@ class LinkPreviewView : LinearLayout {
} }
// intersectedModalSpans should only be a list of one item // intersectedModalSpans should only be a list of one item
val hitSpans = bodyTextView.getIntersectedModalSpans(hitRect) val hitSpans = bodyTextView.getIntersectedModalSpans(hitRect)
hitSpans.forEach { span -> hitSpans.iterator().forEach { span ->
span.onClick(bodyTextView) span.onClick(bodyTextView)
} }
} }

View File

@ -214,7 +214,7 @@ class VisibleMessageContentView : LinearLayout {
val body = getBodySpans(context, message, searchQuery) val body = getBodySpans(context, message, searchQuery)
binding.bodyTextView.text = body binding.bodyTextView.text = body
onContentClick.add { e: MotionEvent -> onContentClick.add { e: MotionEvent ->
binding.bodyTextView.getIntersectedModalSpans(e).forEach { span -> binding.bodyTextView.getIntersectedModalSpans(e).iterator().forEach { span ->
span.onClick(binding.bodyTextView) span.onClick(binding.bodyTextView)
} }
} }

View File

@ -390,7 +390,7 @@ class VisibleMessageView : LinearLayout {
} }
fun onContentClick(event: MotionEvent) { fun onContentClick(event: MotionEvent) {
binding.messageContentView.onContentClick.forEach { clickHandler -> clickHandler.invoke(event) } binding.messageContentView.onContentClick.iterator().forEach { clickHandler -> clickHandler.invoke(event) }
} }
private fun onPress(event: MotionEvent) { private fun onPress(event: MotionEvent) {

View File

@ -11,6 +11,7 @@ import org.session.libsession.utilities.concurrent.SignalExecutors
import org.thoughtcrime.securesms.contacts.ContactAccessor import org.thoughtcrime.securesms.contacts.ContactAccessor
import org.thoughtcrime.securesms.database.CursorList import org.thoughtcrime.securesms.database.CursorList
import org.thoughtcrime.securesms.database.SearchDatabase import org.thoughtcrime.securesms.database.SearchDatabase
import org.thoughtcrime.securesms.database.SessionContactDatabase
import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.search.SearchRepository import org.thoughtcrime.securesms.search.SearchRepository
import org.thoughtcrime.securesms.search.model.MessageResult import org.thoughtcrime.securesms.search.model.MessageResult
@ -20,14 +21,11 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class SearchViewModel @Inject constructor( class SearchViewModel @Inject constructor(
@ApplicationContext context: Context, private val searchRepository: SearchRepository
searchDb: SearchDatabase,
threadDb: ThreadDatabase
) : ViewModel() { ) : ViewModel() {
private val searchRepository: SearchRepository private val result: CloseableLiveData<SearchResult> = CloseableLiveData()
private val result: CloseableLiveData<SearchResult> private val debouncer: Debouncer = Debouncer(500)
private val debouncer: Debouncer
private var firstSearch = false private var firstSearch = false
private var searchOpen = false private var searchOpen = false
private var activeQuery: String? = null private var activeQuery: String? = null
@ -107,13 +105,4 @@ class SearchViewModel @Inject constructor(
} }
} }
init {
result = CloseableLiveData()
debouncer = Debouncer(500)
searchRepository = SearchRepository(context,
searchDb,
threadDb,
ContactAccessor.getInstance(),
SignalExecutors.SERIAL)
}
} }

View File

@ -29,6 +29,7 @@ import org.session.libsignal.database.LokiOpenGroupDatabaseProtocol;
import java.io.Closeable; import java.io.Closeable;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.LinkedList; import java.util.LinkedList;
import java.util.List; import java.util.List;
@ -111,7 +112,7 @@ public class GroupDatabase extends Database implements LokiOpenGroupDatabaseProt
} }
} }
Optional<GroupRecord> getGroup(Cursor cursor) { public Optional<GroupRecord> getGroup(Cursor cursor) {
Reader reader = new Reader(cursor); Reader reader = new Reader(cursor);
return Optional.fromNullable(reader.getCurrent()); return Optional.fromNullable(reader.getCurrent());
} }
@ -146,6 +147,29 @@ public class GroupDatabase extends Database implements LokiOpenGroupDatabaseProt
return groups; return groups;
} }
public Cursor getGroupsFilteredByMembers(List<String> members) {
if (members == null || members.isEmpty()) {
return null;
}
String[] queriesValues = new String[members.size()];
StringBuilder queries = new StringBuilder();
for (int i=0; i < members.size(); i++) {
boolean isEnd = i == (members.size() - 1);
queries.append(MEMBERS + " LIKE ?");
queriesValues[i] = "%"+members.get(i)+"%";
if (!isEnd) {
queries.append(" OR ");
}
}
return databaseHelper.getReadableDatabase().query(TABLE_NAME, null,
queries.toString(),
queriesValues,
null, null, null);
}
public @NonNull List<Recipient> getGroupMembers(String groupId, boolean includeSelf) { public @NonNull List<Recipient> getGroupMembers(String groupId, boolean includeSelf) {
List<Address> members = getCurrentMembers(groupId, false); List<Address> members = getCurrentMembers(groupId, false);
List<Recipient> recipients = new LinkedList<>(); List<Recipient> recipients = new LinkedList<>();

View File

@ -450,7 +450,7 @@ private inline fun <reified T> wrap(x: T): Array<T> {
private fun wrap(x: Map<String, String>): ContentValues { private fun wrap(x: Map<String, String>): ContentValues {
val result = ContentValues(x.size) val result = ContentValues(x.size)
x.forEach { result.put(it.key, it.value) } x.iterator().forEach { result.put(it.key, it.value) }
return result return result
} }
// endregion // endregion

View File

@ -139,7 +139,7 @@ public class MmsSmsDatabase extends Database {
try (Cursor cursor = queryTables(PROJECTION, selection, order, "1")) { try (Cursor cursor = queryTables(PROJECTION, selection, order, "1")) {
cursor.moveToFirst(); cursor.moveToFirst();
return cursor.getLong(cursor.getColumnIndex(MmsSmsColumns.ID)); return cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.ID));
} }
} }
@ -157,7 +157,7 @@ public class MmsSmsDatabase extends Database {
try { try {
return cursor != null ? cursor.getCount() : 0; return cursor != null ? cursor.getCount() : 0;
} finally { } finally {
if (cursor != null) cursor.close();; if (cursor != null) cursor.close();
} }
} }

View File

@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.database; package org.thoughtcrime.securesms.database;
import android.content.Context; import android.content.Context;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import com.annimon.stream.Stream; import com.annimon.stream.Stream;
@ -8,8 +9,8 @@ import com.annimon.stream.Stream;
import net.sqlcipher.Cursor; import net.sqlcipher.Cursor;
import net.sqlcipher.database.SQLiteDatabase; import net.sqlcipher.database.SQLiteDatabase;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.session.libsession.utilities.Util; import org.session.libsession.utilities.Util;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import java.util.List; import java.util.List;
@ -80,7 +81,7 @@ public class SearchDatabase extends Database {
"INNER JOIN " + ThreadDatabase.TABLE_NAME + " ON " + MMS_FTS_TABLE_NAME + "." + 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 ?";
private static final String MESSAGES_FOR_THREAD_QUERY = private static final String MESSAGES_FOR_THREAD_QUERY =
"SELECT " + "SELECT " +
@ -115,7 +116,9 @@ public class SearchDatabase extends Database {
SQLiteDatabase db = databaseHelper.getReadableDatabase(); SQLiteDatabase db = databaseHelper.getReadableDatabase();
String prefixQuery = adjustQuery(query); String prefixQuery = adjustQuery(query);
Cursor cursor = db.rawQuery(MESSAGES_QUERY, new String[] { prefixQuery, prefixQuery }); int queryLimit = Math.min(query.length()*50,500);
Cursor cursor = db.rawQuery(MESSAGES_QUERY, new String[] { prefixQuery, prefixQuery, String.valueOf(queryLimit) });
setNotifyConverationListListeners(cursor); setNotifyConverationListListeners(cursor);
return cursor; return cursor;
} }

View File

@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.database
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context
import androidx.core.database.getStringOrNull
import net.sqlcipher.Cursor import net.sqlcipher.Cursor
import org.session.libsession.messaging.contacts.Contact import org.session.libsession.messaging.contacts.Contact
import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Base64
@ -73,7 +74,7 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da
notifyConversationListListeners() notifyConversationListListeners()
} }
private fun contactFromCursor(cursor: Cursor): Contact { fun contactFromCursor(cursor: Cursor): Contact {
val sessionID = cursor.getString(sessionID) val sessionID = cursor.getString(sessionID)
val contact = Contact(sessionID) val contact = Contact(sessionID)
contact.name = cursor.getStringOrNull(name) contact.name = cursor.getStringOrNull(name)
@ -87,4 +88,29 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da
contact.isTrusted = cursor.getInt(isTrusted) != 0 contact.isTrusted = cursor.getInt(isTrusted) != 0
return contact return contact
} }
fun contactFromCursor(cursor: android.database.Cursor): Contact {
val sessionID = cursor.getString(cursor.getColumnIndexOrThrow(sessionID))
val contact = Contact(sessionID)
contact.name = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(name))
contact.nickname = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(nickname))
contact.profilePictureURL = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(profilePictureURL))
contact.profilePictureFileName = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(profilePictureFileName))
cursor.getStringOrNull(cursor.getColumnIndexOrThrow(profilePictureEncryptionKey))?.let {
contact.profilePictureEncryptionKey = Base64.decode(it)
}
contact.threadID = cursor.getLong(cursor.getColumnIndexOrThrow(threadID))
contact.isTrusted = cursor.getInt(cursor.getColumnIndexOrThrow(isTrusted)) != 0
return contact
}
fun queryContactsByName(constraint: String): Cursor {
return databaseHelper.readableDatabase.query(
sessionContactTable, null, " $name LIKE ? OR $nickname LIKE ?", arrayOf(
"%$constraint%",
"%$constraint%"
),
null, null, null
)
}
} }

View File

@ -339,6 +339,19 @@ public class ThreadDatabase extends Database {
} }
public Cursor searchConversationAddresses(String addressQuery) {
if (addressQuery == null || addressQuery.isEmpty()) {
return null;
}
SQLiteDatabase db = databaseHelper.getReadableDatabase();
String selection = TABLE_NAME + "." + ADDRESS + " LIKE ? AND " + TABLE_NAME + "." + MESSAGE_COUNT + " != 0";
String[] selectionArgs = new String[]{addressQuery+"%"};
String query = createQuery(selection, 0);
Cursor cursor = db.rawQuery(query, selectionArgs);
return cursor;
}
public Cursor getFilteredConversationList(@Nullable List<Address> filter) { public Cursor getFilteredConversationList(@Nullable List<Address> filter) {
if (filter == null || filter.size() == 0) if (filter == null || filter.size() == 0)
return null; return null;
@ -706,14 +719,14 @@ public class ThreadDatabase extends Database {
long count = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.MESSAGE_COUNT)); long count = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.MESSAGE_COUNT));
int unreadCount = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.UNREAD_COUNT)); int unreadCount = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.UNREAD_COUNT));
long type = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.SNIPPET_TYPE)); long type = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.SNIPPET_TYPE));
boolean archived = cursor.getInt(cursor.getColumnIndex(ThreadDatabase.ARCHIVED)) != 0; boolean archived = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.ARCHIVED)) != 0;
int status = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.STATUS)); int status = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.STATUS));
int deliveryReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.DELIVERY_RECEIPT_COUNT)); int deliveryReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.DELIVERY_RECEIPT_COUNT));
int readReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.READ_RECEIPT_COUNT)); int readReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.READ_RECEIPT_COUNT));
long expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.EXPIRES_IN)); long expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.EXPIRES_IN));
long lastSeen = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.LAST_SEEN)); long lastSeen = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.LAST_SEEN));
Uri snippetUri = getSnippetUri(cursor); Uri snippetUri = getSnippetUri(cursor);
boolean pinned = cursor.getInt(cursor.getColumnIndex(ThreadDatabase.IS_PINNED)) != 0; boolean pinned = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.IS_PINNED)) != 0;
if (!TextSecurePreferences.isReadReceiptsEnabled(context)) { if (!TextSecurePreferences.isReadReceiptsEnabled(context)) {
readReceiptCount = 0; readReceiptCount = 0;

View File

@ -200,7 +200,7 @@ class EnterChatURLFragment : Fragment() {
private fun populateDefaultGroups(groups: List<DefaultGroup>) { private fun populateDefaultGroups(groups: List<DefaultGroup>) {
binding.defaultRoomsGridLayout.removeAllViews() binding.defaultRoomsGridLayout.removeAllViews()
binding.defaultRoomsGridLayout.useDefaultMargins = false binding.defaultRoomsGridLayout.useDefaultMargins = false
groups.forEach { defaultGroup -> groups.iterator().forEach { defaultGroup ->
val chip = layoutInflater.inflate(R.layout.default_group_chip, binding.defaultRoomsGridLayout, false) as Chip val chip = layoutInflater.inflate(R.layout.default_group_chip, binding.defaultRoomsGridLayout, false) as Chip
val drawable = defaultGroup.image?.let { bytes -> val drawable = defaultGroup.image?.let { bytes ->
val bitmap = BitmapFactory.decodeByteArray(bytes,0, bytes.size) val bitmap = BitmapFactory.decodeByteArray(bytes,0, bytes.size)

View File

@ -7,10 +7,9 @@ import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.database.Cursor import android.database.Cursor
import android.os.Bundle import android.os.Bundle
import android.text.Spannable
import android.text.SpannableString import android.text.SpannableString
import android.text.style.ForegroundColorSpan
import android.widget.Toast import android.widget.Toast
import androidx.activity.viewModels
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
@ -20,20 +19,24 @@ import androidx.loader.content.Loader
import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.localbroadcastmanager.content.LocalBroadcastManager
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityHomeBinding import network.loki.messenger.databinding.ActivityHomeBinding
import network.loki.messenger.databinding.SeedReminderStubBinding
import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode import org.greenrobot.eventbus.ThreadMode
import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.jobs.JobQueue
import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.sending_receiving.MessageSender
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.ProfilePictureModifiedEvent import org.session.libsession.utilities.ProfilePictureModifiedEvent
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.ThreadUtils import org.session.libsignal.utilities.ThreadUtils
import org.session.libsignal.utilities.toHexString import org.session.libsignal.utilities.toHexString
import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.ApplicationContext
@ -43,6 +46,7 @@ import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.conversation.v2.utilities.NotificationUtils import org.thoughtcrime.securesms.conversation.v2.utilities.NotificationUtils
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.database.GroupDatabase
import org.thoughtcrime.securesms.database.MmsSmsDatabase
import org.thoughtcrime.securesms.database.RecipientDatabase import org.thoughtcrime.securesms.database.RecipientDatabase
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
@ -51,32 +55,42 @@ import org.thoughtcrime.securesms.dms.CreatePrivateChatActivity
import org.thoughtcrime.securesms.groups.CreateClosedGroupActivity import org.thoughtcrime.securesms.groups.CreateClosedGroupActivity
import org.thoughtcrime.securesms.groups.JoinPublicChatActivity import org.thoughtcrime.securesms.groups.JoinPublicChatActivity
import org.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.groups.OpenGroupManager
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter
import org.thoughtcrime.securesms.home.search.GlobalSearchInputLayout
import org.thoughtcrime.securesms.home.search.GlobalSearchViewModel
import org.thoughtcrime.securesms.mms.GlideApp import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.notifications.MarkReadReceiver
import org.thoughtcrime.securesms.onboarding.SeedActivity import org.thoughtcrime.securesms.onboarding.SeedActivity
import org.thoughtcrime.securesms.onboarding.SeedReminderViewDelegate import org.thoughtcrime.securesms.onboarding.SeedReminderViewDelegate
import org.thoughtcrime.securesms.preferences.SettingsActivity import org.thoughtcrime.securesms.preferences.SettingsActivity
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import org.thoughtcrime.securesms.util.IP2Country import org.thoughtcrime.securesms.util.IP2Country
import org.thoughtcrime.securesms.util.UiModeUtilities
import org.thoughtcrime.securesms.util.disableClipping import org.thoughtcrime.securesms.util.disableClipping
import org.thoughtcrime.securesms.util.getColorWithID
import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.util.push
import org.thoughtcrime.securesms.util.show import org.thoughtcrime.securesms.util.show
import java.io.IOException import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class HomeActivity : PassphraseRequiredActionBarActivity(), ConversationClickListener, class HomeActivity : PassphraseRequiredActionBarActivity(),
SeedReminderViewDelegate, NewConversationButtonSetViewDelegate, LoaderManager.LoaderCallbacks<Cursor> { ConversationClickListener,
SeedReminderViewDelegate,
NewConversationButtonSetViewDelegate,
LoaderManager.LoaderCallbacks<Cursor>,
GlobalSearchInputLayout.GlobalSearchInputLayoutListener {
private lateinit var binding: ActivityHomeBinding private lateinit var binding: ActivityHomeBinding
private lateinit var glide: GlideRequests private lateinit var glide: GlideRequests
private var broadcastReceiver: BroadcastReceiver? = null private var broadcastReceiver: BroadcastReceiver? = null
@Inject lateinit var threadDb: ThreadDatabase @Inject lateinit var threadDb: ThreadDatabase
@Inject lateinit var mmsSmsDatabase: MmsSmsDatabase
@Inject lateinit var recipientDatabase: RecipientDatabase @Inject lateinit var recipientDatabase: RecipientDatabase
@Inject lateinit var groupDatabase: GroupDatabase @Inject lateinit var groupDatabase: GroupDatabase
@Inject lateinit var textSecurePreferences: TextSecurePreferences
private val globalSearchViewModel by viewModels<GlobalSearchViewModel>()
private val publicKey: String private val publicKey: String
get() = TextSecurePreferences.getLocalNumber(this)!! get() = TextSecurePreferences.getLocalNumber(this)!!
@ -85,6 +99,46 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), ConversationClickLis
HomeAdapter(context = this, cursor = threadDb.conversationList, listener = this) HomeAdapter(context = this, cursor = threadDb.conversationList, listener = this)
} }
private val globalSearchAdapter = GlobalSearchAdapter { model ->
when (model) {
is GlobalSearchAdapter.Model.Message -> {
val threadId = model.messageResult.threadId
val timestamp = model.messageResult.receivedTimestampMs
val author = model.messageResult.messageRecipient.address
val intent = Intent(this, ConversationActivityV2::class.java)
intent.putExtra(ConversationActivityV2.THREAD_ID, threadId)
intent.putExtra(ConversationActivityV2.SCROLL_MESSAGE_ID, timestamp)
intent.putExtra(ConversationActivityV2.SCROLL_MESSAGE_AUTHOR, author)
push(intent)
}
is GlobalSearchAdapter.Model.SavedMessages -> {
val intent = Intent(this, ConversationActivityV2::class.java)
intent.putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(model.currentUserPublicKey))
push(intent)
}
is GlobalSearchAdapter.Model.Contact -> {
val address = model.contact.sessionID
val intent = Intent(this, ConversationActivityV2::class.java)
intent.putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(address))
push(intent)
}
is GlobalSearchAdapter.Model.GroupConversation -> {
val groupAddress = Address.fromSerialized(model.groupRecord.encodedId)
val threadId = threadDb.getThreadIdIfExistsFor(Recipient.from(this, groupAddress, false))
if (threadId >= 0) {
val intent = Intent(this, ConversationActivityV2::class.java)
intent.putExtra(ConversationActivityV2.THREAD_ID, threadId)
push(intent)
}
}
else -> {
Log.d("Loki", "callback with model: $model")
}
}
}
// region Lifecycle // region Lifecycle
override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) {
super.onCreate(savedInstanceState, isReady) super.onCreate(savedInstanceState, isReady)
@ -98,28 +152,28 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), ConversationClickLis
// Set up toolbar buttons // Set up toolbar buttons
binding.profileButton.glide = glide binding.profileButton.glide = glide
binding.profileButton.setOnClickListener { openSettings() } binding.profileButton.setOnClickListener { openSettings() }
binding.pathStatusViewContainer.disableClipping() binding.searchViewContainer.setOnClickListener {
binding.pathStatusViewContainer.setOnClickListener { showPath() } binding.globalSearchInputLayout.requestFocus()
}
binding.sessionToolbar.disableClipping()
// Set up seed reminder view // Set up seed reminder view
val hasViewedSeed = TextSecurePreferences.getHasViewedSeed(this) val hasViewedSeed = TextSecurePreferences.getHasViewedSeed(this)
if (!hasViewedSeed) { if (!hasViewedSeed) {
binding.seedReminderStub.setOnInflateListener { _, inflated -> binding.seedReminderView.isVisible = true
val stubBinding = SeedReminderStubBinding.bind(inflated) binding.seedReminderView.title = SpannableString("You're almost finished! 80%") // Intentionally not yet translated
val seedReminderViewTitle = SpannableString("You're almost finished! 80%") // Intentionally not yet translated binding.seedReminderView.subtitle = resources.getString(R.string.view_seed_reminder_subtitle_1)
seedReminderViewTitle.setSpan(ForegroundColorSpan(resources.getColorWithID(R.color.accent, theme)), 24, 27, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) binding.seedReminderView.setProgress(80, false)
stubBinding.seedReminderView.title = seedReminderViewTitle binding.seedReminderView.delegate = this@HomeActivity
stubBinding.seedReminderView.subtitle = resources.getString(R.string.view_seed_reminder_subtitle_1)
stubBinding.seedReminderView.setProgress(80, false)
stubBinding.seedReminderView.delegate = this@HomeActivity
}
binding.seedReminderStub.inflate()
} else { } else {
binding.seedReminderStub.isVisible = false binding.seedReminderView.isVisible = false
} }
setupHeaderImage()
// Set up recycler view // Set up recycler view
binding.globalSearchInputLayout.listener = this
homeAdapter.setHasStableIds(true) homeAdapter.setHasStableIds(true)
homeAdapter.glide = glide homeAdapter.glide = glide
binding.recyclerView.adapter = homeAdapter binding.recyclerView.adapter = homeAdapter
binding.globalSearchRecycler.adapter = globalSearchAdapter
// Set up empty state view // Set up empty state view
binding.createNewPrivateChatButton.setOnClickListener { createNewPrivateChat() } binding.createNewPrivateChatButton.setOnClickListener { createNewPrivateChat() }
IP2Country.configureIfNeeded(this@HomeActivity) IP2Country.configureIfNeeded(this@HomeActivity)
@ -129,7 +183,6 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), ConversationClickLis
binding.newConversationButtonSet.delegate = this binding.newConversationButtonSet.delegate = this
// Observe blocked contacts changed events // Observe blocked contacts changed events
val broadcastReceiver = object : BroadcastReceiver() { val broadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
binding.recyclerView.adapter!!.notifyDataSetChanged() binding.recyclerView.adapter!!.notifyDataSetChanged()
} }
@ -161,10 +214,85 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), ConversationClickLis
JobQueue.shared.resumePendingJobs() JobQueue.shared.resumePendingJobs()
} }
} }
// monitor the global search VM query
launch {
binding.globalSearchInputLayout.query
.onEach(globalSearchViewModel::postQuery)
.collect()
}
// Get group results and display them
launch {
globalSearchViewModel.result.collect { result ->
val currentUserPublicKey = publicKey
val contactAndGroupList = result.contacts.map { GlobalSearchAdapter.Model.Contact(it) } +
result.threads.map { GlobalSearchAdapter.Model.GroupConversation(it) }
val contactResults = contactAndGroupList.toMutableList()
if (contactResults.isEmpty()) {
contactResults.add(GlobalSearchAdapter.Model.SavedMessages(currentUserPublicKey))
}
val userIndex = contactResults.indexOfFirst { it is GlobalSearchAdapter.Model.Contact && it.contact.sessionID == currentUserPublicKey }
if (userIndex >= 0) {
contactResults[userIndex] = GlobalSearchAdapter.Model.SavedMessages(currentUserPublicKey)
}
if (contactResults.isNotEmpty()) {
contactResults.add(0, GlobalSearchAdapter.Model.Header(R.string.global_search_contacts_groups))
}
val unreadThreadMap = result.messages
.groupBy { it.threadId }.keys
.map { it to mmsSmsDatabase.getUnreadCount(it) }
.toMap()
val messageResults: MutableList<GlobalSearchAdapter.Model> = result.messages
.map { messageResult ->
GlobalSearchAdapter.Model.Message(
messageResult,
unreadThreadMap[messageResult.threadId] ?: 0
)
}.toMutableList()
if (messageResults.isNotEmpty()) {
messageResults.add(0, GlobalSearchAdapter.Model.Header(R.string.global_search_messages))
}
val newData = contactResults + messageResults
globalSearchAdapter.setNewData(result.query, newData)
}
}
} }
EventBus.getDefault().register(this@HomeActivity) EventBus.getDefault().register(this@HomeActivity)
} }
private fun setupHeaderImage() {
val isDayUiMode = UiModeUtilities.isDayUiMode(this)
val headerTint = if (isDayUiMode) R.color.black else R.color.accent
binding.sessionHeaderImage.setColorFilter(getColor(headerTint))
}
override fun onInputFocusChanged(hasFocus: Boolean) {
if (hasFocus) {
setSearchShown(true)
} else {
setSearchShown(!binding.globalSearchInputLayout.query.value.isNullOrEmpty())
}
}
private fun setSearchShown(isShown: Boolean) {
binding.searchToolbar.isVisible = isShown
binding.sessionToolbar.isVisible = !isShown
binding.recyclerView.isVisible = !isShown
binding.emptyStateContainer.isVisible = (binding.recyclerView.adapter as HomeAdapter).itemCount == 0 && binding.recyclerView.isVisible
binding.seedReminderView.isVisible = !TextSecurePreferences.getHasViewedSeed(this) && !isShown
binding.gradientView.isVisible = !isShown
binding.globalSearchRecycler.isVisible = isShown
binding.newConversationButtonSet.isVisible = !isShown
}
override fun onCreateLoader(id: Int, bundle: Bundle?): Loader<Cursor> { override fun onCreateLoader(id: Int, bundle: Bundle?): Loader<Cursor> {
return HomeLoader(this@HomeActivity) return HomeLoader(this@HomeActivity)
} }
@ -187,7 +315,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), ConversationClickLis
binding.profileButton.update() binding.profileButton.update()
val hasViewedSeed = TextSecurePreferences.getHasViewedSeed(this) val hasViewedSeed = TextSecurePreferences.getHasViewedSeed(this)
if (hasViewedSeed) { if (hasViewedSeed) {
binding.seedReminderStub.isVisible = false binding.seedReminderView.isVisible = false
} }
if (TextSecurePreferences.getConfigurationMessageSynced(this)) { if (TextSecurePreferences.getConfigurationMessageSynced(this)) {
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
@ -221,7 +349,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), ConversationClickLis
// region Updating // region Updating
private fun updateEmptyState() { private fun updateEmptyState() {
val threadCount = (binding.recyclerView.adapter as HomeAdapter).itemCount val threadCount = (binding.recyclerView.adapter as HomeAdapter).itemCount
binding.emptyStateContainer.isVisible = threadCount == 0 binding.emptyStateContainer.isVisible = threadCount == 0 && binding.recyclerView.isVisible
} }
@Subscribe(threadMode = ThreadMode.MAIN) @Subscribe(threadMode = ThreadMode.MAIN)
@ -240,6 +368,14 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), ConversationClickLis
// endregion // endregion
// region Interaction // region Interaction
override fun onBackPressed() {
if (binding.globalSearchRecycler.isVisible) {
binding.globalSearchInputLayout.clearSearch(true)
return
}
super.onBackPressed()
}
override fun handleSeedReminderViewContinueButtonTapped() { override fun handleSeedReminderViewContinueButtonTapped() {
val intent = Intent(this, SeedActivity::class.java) val intent = Intent(this, SeedActivity::class.java)
show(intent) show(intent)

View File

@ -0,0 +1,129 @@
package org.thoughtcrime.securesms.home.search
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.annotation.StringRes
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import network.loki.messenger.R
import network.loki.messenger.databinding.ViewGlobalSearchHeaderBinding
import network.loki.messenger.databinding.ViewGlobalSearchResultBinding
import org.session.libsession.utilities.GroupRecord
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.search.model.MessageResult
import java.security.InvalidParameterException
import org.session.libsession.messaging.contacts.Contact as ContactModel
class GlobalSearchAdapter (private val modelCallback: (Model)->Unit): RecyclerView.Adapter<RecyclerView.ViewHolder>() {
companion object {
const val HEADER_VIEW_TYPE = 0
const val CONTENT_VIEW_TYPE = 1
}
private var data: List<Model> = listOf()
private var query: String? = null
fun setNewData(query: String, newData: List<Model>) {
val diffResult = DiffUtil.calculateDiff(GlobalSearchDiff(this.query, query, data, newData))
this.query = query
data = newData
diffResult.dispatchUpdatesTo(this)
}
override fun getItemViewType(position: Int): Int =
if (data[position] is Model.Header) HEADER_VIEW_TYPE else CONTENT_VIEW_TYPE
override fun getItemCount(): Int = data.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder =
if (viewType == HEADER_VIEW_TYPE) {
HeaderView(
LayoutInflater.from(parent.context)
.inflate(R.layout.view_global_search_header, parent, false)
)
} else {
ContentView(
LayoutInflater.from(parent.context)
.inflate(R.layout.view_global_search_result, parent, false)
, modelCallback)
}
override fun onBindViewHolder(
holder: RecyclerView.ViewHolder,
position: Int,
payloads: MutableList<Any>
) {
val newUpdateQuery: String? = payloads.firstOrNull { it is String } as String?
if (newUpdateQuery != null && holder is ContentView) {
holder.bindPayload(newUpdateQuery, data[position])
return
}
if (holder is HeaderView) {
holder.bind(data[position] as Model.Header)
} else if (holder is ContentView) {
holder.bind(query.orEmpty(), data[position])
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
onBindViewHolder(holder,position, mutableListOf())
}
class HeaderView(view: View) : RecyclerView.ViewHolder(view) {
val binding = ViewGlobalSearchHeaderBinding.bind(view)
fun bind(header: Model.Header) {
binding.searchHeader.setText(header.title)
}
}
override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
if (holder is ContentView) {
holder.binding.searchResultProfilePicture.recycle()
}
}
class ContentView(view: View, private val modelCallback: (Model) -> Unit) : RecyclerView.ViewHolder(view) {
val binding = ViewGlobalSearchResultBinding.bind(view).apply {
searchResultProfilePicture.glide = GlideApp.with(root)
}
fun bindPayload(newQuery: String, model: Model) {
bindQuery(newQuery, model)
}
fun bind(query: String, model: Model) {
binding.searchResultProfilePicture.recycle()
when (model) {
is Model.GroupConversation -> bindModel(query, model)
is Model.Contact -> bindModel(query, model)
is Model.Message -> bindModel(query, model)
is Model.SavedMessages -> bindModel(model)
is Model.Header -> throw InvalidParameterException("Can't display Model.Header as ContentView")
}
binding.root.setOnClickListener { modelCallback(model) }
}
}
data class MessageModel(
val threadRecipient: Recipient,
val messageRecipient: Recipient,
val messageSnippet: String
)
sealed class Model {
data class Header(@StringRes val title: Int) : Model()
data class SavedMessages(val currentUserPublicKey: String): Model()
data class Contact(val contact: ContactModel) : Model()
data class GroupConversation(val groupRecord: GroupRecord) : Model()
data class Message(val messageResult: MessageResult, val unread: Int) : Model()
}
}

View File

@ -0,0 +1,160 @@
package org.thoughtcrime.securesms.home.search
import android.graphics.Typeface
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan
import android.util.TypedValue
import androidx.core.view.isVisible
import androidx.recyclerview.widget.DiffUtil
import network.loki.messenger.R
import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.ContentView
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.GroupConversation
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Message
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.SavedMessages
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.SearchUtil
import java.util.Locale
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Contact as ContactModel
class GlobalSearchDiff(
private val oldQuery: String?,
private val newQuery: String?,
private val oldData: List<GlobalSearchAdapter.Model>,
private val newData: List<GlobalSearchAdapter.Model>
) : DiffUtil.Callback() {
override fun getOldListSize(): Int = oldData.size
override fun getNewListSize(): Int = newData.size
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
oldData[oldItemPosition] == newData[newItemPosition]
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
oldQuery == newQuery && oldData[oldItemPosition] == newData[newItemPosition]
override fun getChangePayload(oldItemPosition: Int, newItemPosition: Int): Any? =
if (oldQuery != newQuery) newQuery
else null
}
private val BoldStyleFactory = { StyleSpan(Typeface.BOLD) }
fun ContentView.bindQuery(query: String, model: GlobalSearchAdapter.Model) {
when (model) {
is ContactModel -> {
binding.searchResultTitle.text = getHighlight(
query,
model.contact.getSearchName()
)
}
is Message -> {
val textSpannable = SpannableStringBuilder()
if (model.messageResult.conversationRecipient != model.messageResult.messageRecipient) {
// group chat, bind
val text = "${model.messageResult.messageRecipient.getSearchName()}: "
textSpannable.append(text)
}
textSpannable.append(getHighlight(
query,
model.messageResult.bodySnippet
))
binding.searchResultSubtitle.text = textSpannable
binding.searchResultSubtitle.isVisible = true
binding.searchResultTitle.text = model.messageResult.conversationRecipient.toShortString()
}
is GroupConversation -> {
binding.searchResultTitle.text = getHighlight(
query,
model.groupRecord.title
)
val membersString = model.groupRecord.members.joinToString { address ->
val recipient = Recipient.from(binding.root.context, address, false)
recipient.name ?: "${address.serialize().take(4)}...${address.serialize().takeLast(4)}"
}
binding.searchResultSubtitle.text = getHighlight(query, membersString)
}
}
}
private fun getHighlight(query: String?, toSearch: String): Spannable? {
return SearchUtil.getHighlightedSpan(Locale.getDefault(), BoldStyleFactory, toSearch, query)
}
fun ContentView.bindModel(query: String?, model: GroupConversation) {
binding.searchResultProfilePicture.isVisible = true
binding.searchResultSavedMessages.isVisible = false
binding.searchResultSubtitle.isVisible = model.groupRecord.isClosedGroup
binding.searchResultTimestamp.isVisible = false
val threadRecipient = Recipient.from(binding.root.context, Address.fromSerialized(model.groupRecord.encodedId), false)
binding.searchResultProfilePicture.update(threadRecipient)
val nameString = model.groupRecord.title
binding.searchResultTitle.text = getHighlight(query, nameString)
val groupRecipients = model.groupRecord.members.map { Recipient.from(binding.root.context, it, false) }
val membersString = groupRecipients.joinToString {
val address = it.address.serialize()
it.name ?: "${address.take(4)}...${address.takeLast(4)}"
}
if (model.groupRecord.isClosedGroup) {
binding.searchResultSubtitle.text = getHighlight(query, membersString)
}
}
fun ContentView.bindModel(query: String?, model: ContactModel) {
binding.searchResultProfilePicture.isVisible = true
binding.searchResultSavedMessages.isVisible = false
binding.searchResultSubtitle.isVisible = false
binding.searchResultTimestamp.isVisible = false
binding.searchResultSubtitle.text = null
val recipient =
Recipient.from(binding.root.context, Address.fromSerialized(model.contact.sessionID), false)
binding.searchResultProfilePicture.update(recipient)
val nameString = model.contact.getSearchName()
binding.searchResultTitle.text = getHighlight(query, nameString)
}
fun ContentView.bindModel(model: SavedMessages) {
binding.searchResultSubtitle.isVisible = false
binding.searchResultTimestamp.isVisible = false
binding.searchResultTitle.setText(R.string.note_to_self)
binding.searchResultProfilePicture.isVisible = false
binding.searchResultSavedMessages.isVisible = true
}
fun ContentView.bindModel(query: String?, model: Message) {
binding.searchResultProfilePicture.isVisible = true
binding.searchResultSavedMessages.isVisible = false
binding.searchResultTimestamp.isVisible = true
// val hasUnreads = model.unread > 0
// binding.unreadCountIndicator.isVisible = hasUnreads
// if (hasUnreads) {
// binding.unreadCountTextView.text = model.unread.toString()
// }
binding.searchResultTimestamp.text = DateUtils.getDisplayFormattedTimeSpanString(binding.root.context, Locale.getDefault(), model.messageResult.receivedTimestampMs)
binding.searchResultProfilePicture.update(model.messageResult.conversationRecipient)
val textSpannable = SpannableStringBuilder()
if (model.messageResult.conversationRecipient != model.messageResult.messageRecipient) {
// group chat, bind
val text = "${model.messageResult.messageRecipient.getSearchName()}: "
textSpannable.append(text)
}
textSpannable.append(getHighlight(
query,
model.messageResult.bodySnippet
))
binding.searchResultSubtitle.text = textSpannable
binding.searchResultTitle.text = model.messageResult.conversationRecipient.toShortString()
binding.searchResultSubtitle.isVisible = true
}
fun Recipient.getSearchName(): String = name ?: address.serialize().let { address -> "${address.take(4)}...${address.takeLast(4)}" }
fun Contact.getSearchName(): String =
if (nickname.isNullOrEmpty()) name ?: "${sessionID.take(4)}...${sessionID.takeLast(4)}"
else "${name ?: "${sessionID.take(4)}...${sessionID.takeLast(4)}"} ($nickname)"

View File

@ -0,0 +1,88 @@
package org.thoughtcrime.securesms.home.search
import android.content.Context
import android.text.Editable
import android.text.TextWatcher
import android.util.AttributeSet
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.View
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.widget.LinearLayout
import android.widget.TextView
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import network.loki.messenger.databinding.ViewGlobalSearchInputBinding
class GlobalSearchInputLayout @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : LinearLayout(context, attrs),
View.OnFocusChangeListener,
View.OnClickListener,
TextWatcher, TextView.OnEditorActionListener {
var binding: ViewGlobalSearchInputBinding = ViewGlobalSearchInputBinding.inflate(LayoutInflater.from(context), this, true)
var listener: GlobalSearchInputLayoutListener? = null
private val _query = MutableStateFlow<CharSequence?>(null)
val query: StateFlow<CharSequence?> = _query
override fun onAttachedToWindow() {
super.onAttachedToWindow()
binding.searchInput.onFocusChangeListener = this
binding.searchInput.addTextChangedListener(this)
binding.searchInput.setOnEditorActionListener(this)
binding.searchCancel.setOnClickListener(this)
binding.searchClear.setOnClickListener(this)
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
}
override fun onFocusChange(v: View?, hasFocus: Boolean) {
if (v === binding.searchInput) {
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(windowToken, 0)
listener?.onInputFocusChanged(hasFocus)
}
}
override fun onEditorAction(v: TextView?, actionId: Int, event: KeyEvent?): Boolean {
if (v === binding.searchInput && actionId == EditorInfo.IME_ACTION_SEARCH) {
binding.searchInput.clearFocus()
return true
}
return false
}
override fun onClick(v: View?) {
if (v === binding.searchCancel) {
clearSearch(true)
} else if (v === binding.searchClear) {
clearSearch(false)
}
}
fun clearSearch(clearFocus: Boolean) {
binding.searchInput.text = null
if (clearFocus) {
binding.searchInput.clearFocus()
}
}
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: Editable?) {
_query.value = s?.toString()
}
interface GlobalSearchInputLayoutListener {
fun onInputFocusChanged(hasFocus: Boolean)
}
}

View File

@ -0,0 +1,34 @@
package org.thoughtcrime.securesms.home.search
import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.utilities.GroupRecord
import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.search.model.MessageResult
import org.thoughtcrime.securesms.search.model.SearchResult
data class GlobalSearchResult(
val query: String,
val contacts: List<Contact>,
val threads: List<GroupRecord>,
val messages: List<MessageResult>
) {
val isEmpty: Boolean
get() = contacts.isEmpty() && threads.isEmpty() && messages.isEmpty()
companion object {
val EMPTY = GlobalSearchResult("", emptyList(), emptyList(), emptyList())
const val SEARCH_LIMIT = 5
fun from(searchResult: SearchResult): GlobalSearchResult {
val query = searchResult.query
val contactList = searchResult.contacts.toList()
val threads = searchResult.conversations.toList()
val messages = searchResult.messages.toList()
searchResult.close()
return GlobalSearchResult(query, contactList, threads, messages)
}
}
}

View File

@ -0,0 +1,69 @@
package org.thoughtcrime.securesms.home.search
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.plus
import org.session.libsignal.utilities.SettableFuture
import org.thoughtcrime.securesms.search.SearchRepository
import org.thoughtcrime.securesms.search.model.SearchResult
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@HiltViewModel
class GlobalSearchViewModel @Inject constructor(private val searchRepository: SearchRepository) : ViewModel() {
private val executor = viewModelScope + SupervisorJob()
private val _result: MutableStateFlow<GlobalSearchResult> =
MutableStateFlow(GlobalSearchResult.EMPTY)
val result: StateFlow<GlobalSearchResult> = _result
private val _queryText: MutableStateFlow<CharSequence> = MutableStateFlow("")
fun postQuery(charSequence: CharSequence?) {
charSequence ?: return
_queryText.value = charSequence
}
init {
//
_queryText
.buffer(onBufferOverflow = BufferOverflow.DROP_OLDEST)
.mapLatest { query ->
if (query.trim().length < 2) {
SearchResult.EMPTY
} else {
// user input delay here in case we get a new query within a few hundred ms
// this coroutine will be cancelled and expensive query will not be run if typing quickly
// first query of 2 characters will be instant however
delay(300)
val settableFuture = SettableFuture<SearchResult>()
searchRepository.query(query.toString(), settableFuture::set)
try {
// search repository doesn't play nicely with suspend functions (yet)
settableFuture.get(10_000, TimeUnit.MILLISECONDS)
} catch (e: Exception) {
SearchResult.EMPTY
}
}
}
.onEach { result ->
// update the latest _result value
_result.value = GlobalSearchResult.from(result)
}
.launchIn(executor)
}
}

View File

@ -105,7 +105,7 @@ public class MediaPickerItemFragment extends Fragment implements MediaPickerItem
onMediaSelectionChanged(new ArrayList<>(viewModel.getSelectedMedia().getValue())); onMediaSelectionChanged(new ArrayList<>(viewModel.getSelectedMedia().getValue()));
} }
viewModel.getMediaInBucket(requireContext(), bucketId).observe(this, adapter::setMedia); viewModel.getMediaInBucket(requireContext(), bucketId).observe(getViewLifecycleOwner(), adapter::setMedia);
initMediaObserver(viewModel); initMediaObserver(viewModel);
} }
@ -178,7 +178,7 @@ public class MediaPickerItemFragment extends Fragment implements MediaPickerItem
} }
private void initMediaObserver(@NonNull MediaSendViewModel viewModel) { private void initMediaObserver(@NonNull MediaSendViewModel viewModel) {
viewModel.getCountButtonState().observe(this, media -> { viewModel.getCountButtonState().observe(getViewLifecycleOwner(), media -> {
requireActivity().invalidateOptionsMenu(); requireActivity().invalidateOptionsMenu();
}); });
} }

View File

@ -60,7 +60,7 @@ class BackgroundPollWorker(val context: Context, params: WorkerParameters) : Wor
val closedGroupPoller = ClosedGroupPollerV2() // Intentionally don't use shared val closedGroupPoller = ClosedGroupPollerV2() // Intentionally don't use shared
val storage = MessagingModuleConfiguration.shared.storage val storage = MessagingModuleConfiguration.shared.storage
val allGroupPublicKeys = storage.getAllClosedGroupPublicKeys() val allGroupPublicKeys = storage.getAllClosedGroupPublicKeys()
allGroupPublicKeys.forEach { closedGroupPoller.poll(it) } allGroupPublicKeys.iterator().forEach { closedGroupPoller.poll(it) }
// Open Groups // Open Groups
val threadDB = DatabaseComponent.get(context).lokiThreadDatabase() val threadDB = DatabaseComponent.get(context).lokiThreadDatabase()

View File

@ -57,7 +57,7 @@ object LokiPushNotificationManager {
// Unsubscribe from all closed groups // Unsubscribe from all closed groups
val allClosedGroupPublicKeys = DatabaseComponent.get(context).lokiAPIDatabase().getAllClosedGroupPublicKeys() val allClosedGroupPublicKeys = DatabaseComponent.get(context).lokiAPIDatabase().getAllClosedGroupPublicKeys()
val userPublicKey = TextSecurePreferences.getLocalNumber(context)!! val userPublicKey = TextSecurePreferences.getLocalNumber(context)!!
allClosedGroupPublicKeys.forEach { closedGroup -> allClosedGroupPublicKeys.iterator().forEach { closedGroup ->
performOperation(context, ClosedGroupOperation.Unsubscribe, closedGroup, userPublicKey) performOperation(context, ClosedGroupOperation.Unsubscribe, closedGroup, userPublicKey)
} }
} }
@ -87,7 +87,7 @@ object LokiPushNotificationManager {
} }
// Subscribe to all closed groups // Subscribe to all closed groups
val allClosedGroupPublicKeys = DatabaseComponent.get(context).lokiAPIDatabase().getAllClosedGroupPublicKeys() val allClosedGroupPublicKeys = DatabaseComponent.get(context).lokiAPIDatabase().getAllClosedGroupPublicKeys()
allClosedGroupPublicKeys.forEach { closedGroup -> allClosedGroupPublicKeys.iterator().forEach { closedGroup ->
performOperation(context, ClosedGroupOperation.Subscribe, closedGroup, publicKey) performOperation(context, ClosedGroupOperation.Subscribe, closedGroup, publicKey)
} }
} }

View File

@ -54,9 +54,9 @@ public abstract class CorrectedPreferenceFragment extends PreferenceFragmentComp
} }
@Override @Override
@SuppressLint("RestrictedApi")
protected RecyclerView.Adapter onCreateAdapter(PreferenceScreen preferenceScreen) { protected RecyclerView.Adapter onCreateAdapter(PreferenceScreen preferenceScreen) {
return new PreferenceGroupAdapter(preferenceScreen) { return new PreferenceGroupAdapter(preferenceScreen) {
@SuppressLint("RestrictedApi")
@Override @Override
public void onBindViewHolder(PreferenceViewHolder holder, int position) { public void onBindViewHolder(PreferenceViewHolder holder, int position) {
super.onBindViewHolder(holder, position); super.onBindViewHolder(holder, position);

View File

@ -34,6 +34,7 @@ import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.avatar.AvatarSelection import org.thoughtcrime.securesms.avatar.AvatarSelection
import org.thoughtcrime.securesms.home.PathActivity
import org.thoughtcrime.securesms.mms.GlideApp import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.permissions.Permissions
@ -42,7 +43,9 @@ import org.thoughtcrime.securesms.util.BitmapDecodingException
import org.thoughtcrime.securesms.util.BitmapUtil import org.thoughtcrime.securesms.util.BitmapUtil
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import org.thoughtcrime.securesms.util.UiModeUtilities import org.thoughtcrime.securesms.util.UiModeUtilities
import org.thoughtcrime.securesms.util.disableClipping
import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.util.push
import org.thoughtcrime.securesms.util.show
import java.io.File import java.io.File
import java.security.SecureRandom import java.security.SecureRandom
import java.util.Date import java.util.Date
@ -84,6 +87,8 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
publicKeyTextView.text = hexEncodedPublicKey publicKeyTextView.text = hexEncodedPublicKey
copyButton.setOnClickListener { copyPublicKey() } copyButton.setOnClickListener { copyPublicKey() }
shareButton.setOnClickListener { sharePublicKey() } shareButton.setOnClickListener { sharePublicKey() }
pathButton.setOnClickListener { showPath() }
pathContainer.disableClipping()
privacyButton.setOnClickListener { showPrivacySettings() } privacyButton.setOnClickListener { showPrivacySettings() }
notificationsButton.setOnClickListener { showNotificationSettings() } notificationsButton.setOnClickListener { showNotificationSettings() }
chatsButton.setOnClickListener { showChatSettings() } chatsButton.setOnClickListener { showChatSettings() }
@ -303,6 +308,11 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
} }
} }
private fun showPath() {
val intent = Intent(this, PathActivity::class.java)
show(intent)
}
private fun showSurvey() { private fun showSurvey() {
try { try {
val url = "https://getsession.org/survey" val url = "https://getsession.org/survey"

View File

@ -0,0 +1,33 @@
package org.thoughtcrime.securesms.search
import android.content.Context
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityComponent
import dagger.hilt.android.components.ViewModelComponent
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.scopes.ActivityScoped
import dagger.hilt.android.scopes.ViewModelScoped
import org.session.libsession.utilities.concurrent.SignalExecutors
import org.thoughtcrime.securesms.contacts.ContactAccessor
import org.thoughtcrime.securesms.database.GroupDatabase
import org.thoughtcrime.securesms.database.SearchDatabase
import org.thoughtcrime.securesms.database.SessionContactDatabase
import org.thoughtcrime.securesms.database.ThreadDatabase
@Module
@InstallIn(ViewModelComponent::class)
object SearchModule {
@Provides
@ViewModelScoped
fun provideSearchRepository(@ApplicationContext context: Context,
searchDatabase: SearchDatabase,
threadDatabase: ThreadDatabase,
groupDatabase: GroupDatabase,
contactDatabase: SessionContactDatabase) =
SearchRepository(context, searchDatabase, threadDatabase, groupDatabase, contactDatabase, ContactAccessor.getInstance(), SignalExecutors.SERIAL)
}

View File

@ -3,29 +3,39 @@ package org.thoughtcrime.securesms.search;
import android.content.Context; import android.content.Context;
import android.database.Cursor; import android.database.Cursor;
import android.database.DatabaseUtils; import android.database.DatabaseUtils;
import androidx.annotation.NonNull; import android.database.MergeCursor;
import android.text.TextUtils; import android.text.TextUtils;
import androidx.annotation.NonNull;
import com.annimon.stream.Stream; import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.contacts.ContactAccessor; import org.session.libsession.messaging.contacts.Contact;
import org.session.libsession.utilities.Address; import org.session.libsession.utilities.Address;
import org.session.libsession.utilities.GroupRecord;
import org.session.libsession.utilities.TextSecurePreferences;
import org.session.libsession.utilities.recipients.Recipient;
import org.session.libsignal.utilities.Log;
import org.thoughtcrime.securesms.contacts.ContactAccessor;
import org.thoughtcrime.securesms.database.CursorList; import org.thoughtcrime.securesms.database.CursorList;
import org.thoughtcrime.securesms.database.GroupDatabase;
import org.thoughtcrime.securesms.database.MmsSmsColumns; import org.thoughtcrime.securesms.database.MmsSmsColumns;
import org.thoughtcrime.securesms.database.SearchDatabase; import org.thoughtcrime.securesms.database.SearchDatabase;
import org.thoughtcrime.securesms.database.SessionContactDatabase;
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.session.libsignal.utilities.Log;
import org.session.libsession.utilities.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 org.thoughtcrime.securesms.util.Stopwatch;
import java.util.ArrayList;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.concurrent.Executor; import java.util.concurrent.Executor;
import kotlin.Pair;
/** /**
* Manages data retrieval for search. * Manages data retrieval for search.
*/ */
@ -53,18 +63,24 @@ public class SearchRepository {
private final Context context; private final Context context;
private final SearchDatabase searchDatabase; private final SearchDatabase searchDatabase;
private final ThreadDatabase threadDatabase; private final ThreadDatabase threadDatabase;
private final GroupDatabase groupDatabase;
private final SessionContactDatabase contactDatabase;
private final ContactAccessor contactAccessor; private final ContactAccessor contactAccessor;
private final Executor executor; private final Executor executor;
public SearchRepository(@NonNull Context context, public SearchRepository(@NonNull Context context,
@NonNull SearchDatabase searchDatabase, @NonNull SearchDatabase searchDatabase,
@NonNull ThreadDatabase threadDatabase, @NonNull ThreadDatabase threadDatabase,
@NonNull GroupDatabase groupDatabase,
@NonNull SessionContactDatabase contactDatabase,
@NonNull ContactAccessor contactAccessor, @NonNull ContactAccessor contactAccessor,
@NonNull Executor executor) @NonNull Executor executor)
{ {
this.context = context.getApplicationContext(); this.context = context.getApplicationContext();
this.searchDatabase = searchDatabase; this.searchDatabase = searchDatabase;
this.threadDatabase = threadDatabase; this.threadDatabase = threadDatabase;
this.groupDatabase = groupDatabase;
this.contactDatabase = contactDatabase;
this.contactAccessor = contactAccessor; this.contactAccessor = contactAccessor;
this.executor = executor; this.executor = executor;
} }
@ -81,10 +97,10 @@ public class SearchRepository {
String cleanQuery = sanitizeQuery(query); String cleanQuery = sanitizeQuery(query);
timer.split("clean"); timer.split("clean");
CursorList<Recipient> contacts = queryContacts(cleanQuery); Pair<CursorList<Contact>, List<String>> contacts = queryContacts(cleanQuery);
timer.split("contacts"); timer.split("contacts");
CursorList<ThreadRecord> conversations = queryConversations(cleanQuery); CursorList<GroupRecord> conversations = queryConversations(cleanQuery, contacts.getSecond());
timer.split("conversations"); timer.split("conversations");
CursorList<MessageResult> messages = queryMessages(cleanQuery); CursorList<MessageResult> messages = queryMessages(cleanQuery);
@ -92,7 +108,7 @@ public class SearchRepository {
timer.stop(TAG); timer.stop(TAG);
callback.onResult(new SearchResult(cleanQuery, contacts, conversations, messages)); callback.onResult(new SearchResult(cleanQuery, contacts.getFirst(), conversations, messages));
}); });
} }
@ -111,27 +127,61 @@ public class SearchRepository {
}); });
} }
private CursorList<Recipient> queryContacts(String query) { private Pair<CursorList<Contact>, List<String>> queryContacts(String query) {
return CursorList.emptyList();
/* Loki - We don't need contacts permission Cursor contacts = contactDatabase.queryContactsByName(query);
if (!Permissions.hasAny(context, Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)) { List<Address> contactList = new ArrayList<>();
return CursorList.emptyList(); List<String> contactStrings = new ArrayList<>();
while (contacts.moveToNext()) {
try {
Contact contact = contactDatabase.contactFromCursor(contacts);
String contactSessionId = contact.getSessionID();
Address address = Address.fromSerialized(contactSessionId);
contactList.add(address);
contactStrings.add(contactSessionId);
} catch (Exception e) {
Log.e("Loki", "Error building Contact from cursor in query", e);
}
} }
Cursor textSecureContacts = contactsDatabase.queryTextSecureContacts(query); contacts.close();
Cursor systemContacts = contactsDatabase.querySystemContacts(query);
MergeCursor contacts = new MergeCursor(new Cursor[]{ textSecureContacts, systemContacts }); Cursor addressThreads = threadDatabase.searchConversationAddresses(query);
Cursor individualRecipients = threadDatabase.getFilteredConversationList(contactList);
if (individualRecipients == null && addressThreads == null) {
return new Pair<>(CursorList.emptyList(),contactStrings);
}
MergeCursor merged = new MergeCursor(new Cursor[]{addressThreads, individualRecipients});
return new Pair<>(new CursorList<>(merged, new ContactModelBuilder(contactDatabase, threadDatabase)), contactStrings);
return new CursorList<>(contacts, new RecipientModelBuilder(context));
*/
} }
private CursorList<ThreadRecord> queryConversations(@NonNull String query) { private CursorList<GroupRecord> queryConversations(@NonNull String query, List<String> matchingAddresses) {
List<String> numbers = contactAccessor.getNumbersForThreadSearchFilter(context, query); List<String> numbers = contactAccessor.getNumbersForThreadSearchFilter(context, query);
List<Address> addresses = Stream.of(numbers).map(number -> Address.fromExternal(context, number)).toList(); String localUserNumber = TextSecurePreferences.getLocalNumber(context);
if (localUserNumber != null) {
matchingAddresses.remove(localUserNumber);
}
Set<Address> addresses = new HashSet<>(Stream.of(numbers).map(number -> Address.fromExternal(context, number)).toList());
Cursor conversations = threadDatabase.getFilteredConversationList(addresses); Cursor membersGroupList = groupDatabase.getGroupsFilteredByMembers(matchingAddresses);
return conversations != null ? new CursorList<>(conversations, new ThreadModelBuilder(threadDatabase)) if (membersGroupList != null) {
GroupDatabase.Reader reader = new GroupDatabase.Reader(membersGroupList);
while (membersGroupList.moveToNext()) {
GroupRecord record = reader.getCurrent();
if (record == null) continue;
addresses.add(Address.fromSerialized(record.getEncodedId()));
}
membersGroupList.close();
}
Cursor conversations = threadDatabase.getFilteredConversationList(new ArrayList<>(addresses));
return conversations != null ? new CursorList<>(conversations, new GroupModelBuilder(threadDatabase, groupDatabase))
: CursorList.emptyList(); : CursorList.emptyList();
} }
@ -169,6 +219,28 @@ public class SearchRepository {
return out.toString(); return out.toString();
} }
private static class ContactModelBuilder implements CursorList.ModelBuilder<Contact> {
private final SessionContactDatabase contactDb;
private final ThreadDatabase threadDb;
public ContactModelBuilder(SessionContactDatabase contactDb, ThreadDatabase threadDb) {
this.contactDb = contactDb;
this.threadDb = threadDb;
}
@Override
public Contact build(@NonNull Cursor cursor) {
ThreadRecord threadRecord = threadDb.readerFor(cursor).getCurrent();
Contact contact = contactDb.getContactWithSessionID(threadRecord.getRecipient().getAddress().serialize());
if (contact == null) {
contact = new Contact(threadRecord.getRecipient().getAddress().serialize());
contact.setThreadID(threadRecord.getThreadId());
}
return contact;
}
}
private static class RecipientModelBuilder implements CursorList.ModelBuilder<Recipient> { private static class RecipientModelBuilder implements CursorList.ModelBuilder<Recipient> {
private final Context context; private final Context context;
@ -184,6 +256,22 @@ public class SearchRepository {
} }
} }
private static class GroupModelBuilder implements CursorList.ModelBuilder<GroupRecord> {
private final ThreadDatabase threadDatabase;
private final GroupDatabase groupDatabase;
public GroupModelBuilder(ThreadDatabase threadDatabase, GroupDatabase groupDatabase) {
this.threadDatabase = threadDatabase;
this.groupDatabase = groupDatabase;
}
@Override
public GroupRecord build(@NonNull Cursor cursor) {
ThreadRecord threadRecord = threadDatabase.readerFor(cursor).getCurrent();
return groupDatabase.getGroup(threadRecord.getRecipient().getAddress().toGroupString()).get();
}
}
private static class ThreadModelBuilder implements CursorList.ModelBuilder<ThreadRecord> { private static class ThreadModelBuilder implements CursorList.ModelBuilder<ThreadRecord> {
private final ThreadDatabase threadDatabase; private final ThreadDatabase threadDatabase;
@ -208,7 +296,7 @@ public class SearchRepository {
@Override @Override
public MessageResult build(@NonNull Cursor cursor) { public MessageResult build(@NonNull Cursor cursor) {
Address conversationAddress = Address.fromSerialized(cursor.getString(cursor.getColumnIndex(SearchDatabase.CONVERSATION_ADDRESS))); Address conversationAddress = Address.fromSerialized(cursor.getString(cursor.getColumnIndexOrThrow(SearchDatabase.CONVERSATION_ADDRESS)));
Address messageAddress = Address.fromSerialized(cursor.getString(cursor.getColumnIndexOrThrow(SearchDatabase.MESSAGE_ADDRESS))); Address messageAddress = Address.fromSerialized(cursor.getString(cursor.getColumnIndexOrThrow(SearchDatabase.MESSAGE_ADDRESS)));
Recipient conversationRecipient = Recipient.from(context, conversationAddress, false); Recipient conversationRecipient = Recipient.from(context, conversationAddress, false);
Recipient messageRecipient = Recipient.from(context, messageAddress, false); Recipient messageRecipient = Recipient.from(context, messageAddress, false);

View File

@ -4,9 +4,10 @@ import android.database.ContentObserver;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import org.session.libsession.messaging.contacts.Contact;
import org.session.libsession.utilities.GroupRecord;
import org.thoughtcrime.securesms.database.CursorList; import org.thoughtcrime.securesms.database.CursorList;
import org.thoughtcrime.securesms.database.model.ThreadRecord; import org.thoughtcrime.securesms.database.model.ThreadRecord;
import org.session.libsession.utilities.recipients.Recipient;
import java.util.List; import java.util.List;
@ -19,13 +20,13 @@ public class SearchResult {
public static final SearchResult EMPTY = new SearchResult("", CursorList.emptyList(), CursorList.emptyList(), CursorList.emptyList()); public static final SearchResult EMPTY = new SearchResult("", CursorList.emptyList(), CursorList.emptyList(), CursorList.emptyList());
private final String query; private final String query;
private final CursorList<Recipient> contacts; private final CursorList<Contact> contacts;
private final CursorList<ThreadRecord> conversations; private final CursorList<GroupRecord> conversations;
private final CursorList<MessageResult> messages; private final CursorList<MessageResult> messages;
public SearchResult(@NonNull String query, public SearchResult(@NonNull String query,
@NonNull CursorList<Recipient> contacts, @NonNull CursorList<Contact> contacts,
@NonNull CursorList<ThreadRecord> conversations, @NonNull CursorList<GroupRecord> conversations,
@NonNull CursorList<MessageResult> messages) @NonNull CursorList<MessageResult> messages)
{ {
this.query = query; this.query = query;
@ -34,11 +35,11 @@ public class SearchResult {
this.messages = messages; this.messages = messages;
} }
public List<Recipient> getContacts() { public List<Contact> getContacts() {
return contacts; return contacts;
} }
public List<ThreadRecord> getConversations() { public List<GroupRecord> getConversations() {
return conversations; return conversations;
} }

View File

@ -229,7 +229,7 @@ object BackupUtil {
@JvmOverloads @JvmOverloads
fun deleteAllBackupFiles(context: Context, except: Collection<BackupFileRecord>? = null) { fun deleteAllBackupFiles(context: Context, except: Collection<BackupFileRecord>? = null) {
val db = DatabaseComponent.get(context).lokiBackupFilesDatabase() val db = DatabaseComponent.get(context).lokiBackupFilesDatabase()
db.getBackupFiles().forEach { record -> db.getBackupFiles().iterator().forEach { record ->
if (except != null && except.contains(record)) return@forEach if (except != null && except.contains(record)) return@forEach
// Try to delete the related file. The operation may fail in many cases // Try to delete the related file. The operation may fail in many cases

View File

@ -121,14 +121,12 @@ public class DateUtils extends android.text.format.DateUtils {
* e.g. 2020-09-04T19:17:51Z * e.g. 2020-09-04T19:17:51Z
* https://www.iso.org/iso-8601-date-and-time-format.html * https://www.iso.org/iso-8601-date-and-time-format.html
* *
* Note: SDK_INT == 0 check needed to pass unit tests due to JVM date parser differences.
*
* @return The timestamp if able to be parsed, otherwise -1. * @return The timestamp if able to be parsed, otherwise -1.
*/ */
@SuppressLint("ObsoleteSdkInt") @SuppressLint("ObsoleteSdkInt")
public static long parseIso8601(@Nullable String date) { public static long parseIso8601(@Nullable String date) {
SimpleDateFormat format; SimpleDateFormat format;
if (Build.VERSION.SDK_INT == 0 || Build.VERSION.SDK_INT >= 24) { if (Build.VERSION.SDK_INT >= 24) {
format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssX", Locale.getDefault()); format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssX", Locale.getDefault());
} else { } else {
format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault()); format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault());

View File

@ -116,8 +116,8 @@ class IP2Country private constructor(private val context: Context) {
private fun populateCacheIfNeeded() { private fun populateCacheIfNeeded() {
ThreadUtils.queue { ThreadUtils.queue {
OnionRequestAPI.paths.forEach { path -> OnionRequestAPI.paths.iterator().forEach { path ->
path.forEach { snode -> path.iterator().forEach { snode ->
cacheCountryForIP(snode.ip) // Preload if needed cacheCountryForIP(snode.ip) // Preload if needed
} }
} }

View File

@ -1,12 +1,13 @@
package org.thoughtcrime.securesms.util; package org.thoughtcrime.securesms.util;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import android.text.Spannable; import android.text.Spannable;
import android.text.SpannableString; import android.text.SpannableString;
import android.text.TextUtils; import android.text.TextUtils;
import android.text.style.CharacterStyle; import android.text.style.CharacterStyle;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.annimon.stream.Stream; import com.annimon.stream.Stream;
import org.session.libsignal.utilities.Pair; import org.session.libsignal.utilities.Pair;

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M17,3L7,3c-1.1,0 -2,0.9 -2,2v16l7,-3 7,3L19,5c0,-1.1 -0.9,-2 -2,-2zM17,18l-5,-2.18L7,18L7,5h10v13z"/>
</vector>

View File

@ -0,0 +1,27 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="766.2dp"
android:height="102.3dp"
android:viewportWidth="766.2"
android:viewportHeight="102.3">
<path
android:pathData="M0.004,68.141h25.5c-0.1,6.5 8.3,13.3 28.3,13.7c18.7,0.1 33.1,-2.4 33.1,-11.1c0,-18.9 -84.7,3.2 -84.7,-38.7c0,-20 20.8,-32.1 51.6,-32.1c32,0 53.7,13.3 54,34L82.304,33.941c0.1,-6.7 -7.1,-13.6 -26.7,-13.7C38.704,20.141 26.404,22.841 26.404,31.041c0,18.3 84.5,-2 84.5,38.9c0,20.1 -22.1,32.3 -55.1,32.3C21.704,102.141 -0.396,88.641 0.004,68.141"
android:fillColor="#ffffff"/>
<path
android:pathData="M125.723,2.247h95.2v20.4L149.723,22.647v18.1h69.9v19.1L149.723,59.847L149.723,79.647h71.2v20.4L125.723,100.047L125.723,2.247z"
android:fillColor="#ffffff"/>
<path
android:pathData="M234.365,68.141h25.5c-0.1,6.5 8.3,13.3 28.3,13.7c18.7,0.1 33.1,-2.4 33.1,-11.1c0,-18.9 -84.7,3.2 -84.7,-38.7c0,-20 20.8,-32.1 51.6,-32.1c32,0 53.7,13.3 54,34h-25.5c0.1,-6.7 -7.1,-13.6 -26.7,-13.7C273.065,20.141 260.765,22.841 260.765,31.041c0,18.3 84.5,-2 84.5,38.9c0,20.1 -22.1,32.3 -55.1,32.3C256.065,102.141 233.965,88.641 234.365,68.141"
android:fillColor="#ffffff"/>
<path
android:pathData="M354.285,68.141L379.685,68.141c-0.1,6.5 8.3,13.3 28.3,13.7c18.7,0.1 33.1,-2.4 33.1,-11.1c0,-18.9 -84.7,3.2 -84.7,-38.7c0,-20 20.8,-32.1 51.6,-32.1c32,0 53.7,13.3 54,34h-25.5c0.1,-6.7 -7.1,-13.6 -26.7,-13.7C392.885,20.141 380.685,22.841 380.685,31.041c0,18.3 84.5,-2 84.5,38.9c0,20.1 -22.1,32.3 -55.1,32.3C375.985,102.141 353.885,88.641 354.285,68.141"
android:fillColor="#ffffff"/>
<path
android:pathData="M480,2.3h24v97.6h-24V2.3z"
android:fillColor="#ffffff"/>
<path
android:pathData="M616.48,51.111c0,-19.9 -14.4,-30.7 -37.9,-30.7c-23.3,0 -37.7,10.9 -37.7,30.7s14.4,30.7 37.7,30.7C602.08,81.812 616.48,70.911 616.48,51.111M517.08,51.111c0,-30.7 23.6,-51.1 61.7,-51.1s61.7,20.4 61.7,51.1s-23.6,51.1 -61.9,51.1C540.68,102.212 517.08,81.812 517.08,51.111"
android:fillColor="#ffffff"/>
<path
android:pathData="M653.525,2.247h20.1L742.125,64.447v-62.1h24v97.7h-20.3l-68.4,-62.1v62.1h-24L653.425,2.247z"
android:fillColor="#ffffff"/>
</vector>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape android:shape="rectangle"
xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="?searchBackgroundColor"/>
<corners android:radius="32dp"/>
</shape>

Binary file not shown.

View File

@ -21,6 +21,7 @@
android:orientation="vertical"> android:orientation="vertical">
<RelativeLayout <RelativeLayout
android:id="@+id/session_toolbar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize" android:layout_height="?attr/actionBarSize"
android:layout_marginLeft="20dp" android:layout_marginLeft="20dp"
@ -34,44 +35,64 @@
android:layout_centerVertical="true" android:layout_centerVertical="true"
android:layout_marginLeft="9dp" /> android:layout_marginLeft="9dp" />
<TextView <org.thoughtcrime.securesms.home.PathStatusView
android:id="@+id/pathStatusView"
android:layout_alignBottom="@+id/profileButton"
android:layout_alignEnd="@+id/profileButton"
android:layout_width="@dimen/path_status_view_size"
android:layout_height="@dimen/path_status_view_size"/>
<ImageView
android:id="@+id/sessionHeaderImage"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_alignParentLeft="true" android:layout_centerInParent="true"
android:layout_centerVertical="true" android:layout_toStartOf="@+id/searchViewContainer"
android:layout_marginLeft="64dp" android:layout_toEndOf="@+id/profileButton"
android:fontFamily="sans-serif-medium" android:padding="@dimen/medium_spacing"
android:text="@string/app_name" android:scaleType="centerInside"
android:textColor="@color/text" android:src="@drawable/ic_session"
android:textSize="@dimen/very_large_font_size" /> app:tint="@color/black" />
<RelativeLayout <RelativeLayout
android:id="@+id/pathStatusViewContainer" android:id="@+id/searchViewContainer"
android:layout_width="@dimen/small_profile_picture_size" android:layout_width="@dimen/small_profile_picture_size"
android:layout_height="@dimen/small_profile_picture_size" android:layout_height="@dimen/small_profile_picture_size"
android:layout_alignParentRight="true" android:layout_alignParentRight="true"
android:layout_centerVertical="true"> android:layout_centerVertical="true">
<org.thoughtcrime.securesms.home.PathStatusView <ImageView
android:layout_width="@dimen/path_status_view_size" android:layout_width="wrap_content"
android:layout_height="@dimen/path_status_view_size" android:layout_height="wrap_content"
android:layout_alignParentRight="true" android:layout_centerInParent="true"
android:layout_centerVertical="true" android:src="@drawable/ic_baseline_search_24"
android:layout_marginRight="8dp" /> app:tint="@color/text" />
</RelativeLayout> </RelativeLayout>
</RelativeLayout> </RelativeLayout>
<RelativeLayout
android:visibility="gone"
android:id="@+id/search_toolbar"
android:layout_marginHorizontal="@dimen/medium_spacing"
android:layout_width="match_parent"
android:layout_height="?actionBarSize">
<org.thoughtcrime.securesms.home.search.GlobalSearchInputLayout
android:layout_centerVertical="true"
android:id="@+id/globalSearchInputLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
</RelativeLayout>
<View <View
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="1px" android:layout_height="1px"
android:background="?android:dividerHorizontal" android:background="?android:dividerHorizontal"
android:elevation="1dp" /> android:elevation="1dp" />
<ViewStub <org.thoughtcrime.securesms.onboarding.SeedReminderView
android:id="@+id/seedReminderStub" android:id="@+id/seedReminderView"
android:layout="@layout/seed_reminder_stub"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" /> android:layout_height="wrap_content" />
@ -100,6 +121,16 @@
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="@drawable/home_activity_gradient" /> android:background="@drawable/home_activity_gradient" />
<androidx.recyclerview.widget.RecyclerView
android:visibility="gone"
android:scrollbars="vertical"
android:id="@+id/globalSearchRecycler"
android:layout_width="match_parent"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/view_global_search_result"
tools:itemCount="6"
android:layout_height="match_parent"/>
<LinearLayout <LinearLayout
android:id="@+id/emptyStateContainer" android:id="@+id/emptyStateContainer"
android:layout_width="wrap_content" android:layout_width="wrap_content"

View File

@ -111,6 +111,39 @@
android:layout_marginTop="@dimen/large_spacing" android:layout_marginTop="@dimen/large_spacing"
android:background="?android:dividerHorizontal" /> android:background="?android:dividerHorizontal" />
<com.google.android.flexbox.FlexboxLayout
android:id="@+id/pathButton"
android:orientation="horizontal"
android:layout_width="match_parent"
app:justifyContent="center"
app:alignItems="center"
android:layout_height="@dimen/setting_button_height"
android:background="@drawable/setting_button_background">
<TextView
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:textColor="@color/text"
android:textSize="@dimen/medium_font_size"
android:layout_gravity="center"
android:textStyle="bold"
android:gravity="center"
android:text="@string/activity_path_title" />
<FrameLayout
android:id="@+id/pathContainer"
android:layout_width="@dimen/small_profile_picture_size"
android:layout_height="@dimen/small_profile_picture_size">
<org.thoughtcrime.securesms.home.PathStatusView
android:layout_gravity="center"
android:layout_width="@dimen/path_status_view_size"
android:layout_height="@dimen/path_status_view_size"/>
</FrameLayout>
</com.google.android.flexbox.FlexboxLayout>
<View
android:layout_width="match_parent"
android:layout_height="1px"
android:background="?android:dividerHorizontal" />
<TextView <TextView
android:id="@+id/privacyButton" android:id="@+id/privacyButton"
android:layout_width="match_parent" android:layout_width="match_parent"
@ -275,9 +308,9 @@
</ScrollView> </ScrollView>
<FrameLayout <FrameLayout
android:animateLayoutChanges="true"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent"
android:animateLayoutChanges="true">
<RelativeLayout <RelativeLayout
android:id="@+id/loader" android:id="@+id/loader"
@ -290,8 +323,8 @@
style="@style/SpinKitView.Large.ThreeBounce" style="@style/SpinKitView.Large.ThreeBounce"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_centerInParent="true" android:layout_centerInParent="true"
android:layout_marginTop="8dp"
app:SpinKit_Color="@android:color/white" /> app:SpinKit_Color="@android:color/white" />
</RelativeLayout> </RelativeLayout>

View File

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android" <merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto">
<ImageView <ImageView
android:id="@+id/sms_failed_indicator" android:id="@+id/sms_failed_indicator"
@ -8,7 +9,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:src="@drawable/ic_error" android:src="@drawable/ic_error"
android:visibility="gone" android:visibility="gone"
android:tint="@color/core_red" app:tint="@color/core_red"
tools:visibility="visible" tools:visibility="visible"
android:contentDescription="@string/conversation_item_sent__send_failed_indicator_description" /> android:contentDescription="@string/conversation_item_sent__send_failed_indicator_description" />
@ -17,7 +18,7 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:src="@drawable/ic_error" android:src="@drawable/ic_error"
android:tint="@color/core_grey_60" app:tint="@color/core_grey_60"
android:visibility="gone" android:visibility="gone"
tools:visibility="visible" tools:visibility="visible"
android:layout_gravity="center_vertical" android:layout_gravity="center_vertical"

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<FrameLayout <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical" android:orientation="vertical"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
@ -30,7 +30,7 @@
android:layout_width="36dp" android:layout_width="36dp"
android:layout_height="36dp" android:layout_height="36dp"
android:src="@drawable/ic_baseline_clear_24" android:src="@drawable/ic_baseline_clear_24"
android:tint="@android:color/white"/> app:tint="@android:color/white"/>
</FrameLayout> </FrameLayout>

View File

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android" <merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto">
<ImageView <ImageView
android:id="@+id/pending_indicator" android:id="@+id/pending_indicator"
@ -38,7 +39,7 @@
android:paddingStart="2dp" android:paddingStart="2dp"
android:visibility="gone" android:visibility="gone"
android:contentDescription="@string/conversation_item_sent__message_read" android:contentDescription="@string/conversation_item_sent__message_read"
android:tint="@color/core_blue" app:tint="@color/core_blue"
tools:visibility="visible"/> tools:visibility="visible"/>
</merge> </merge>

View File

@ -33,6 +33,6 @@
android:layout_height="7dp" android:layout_height="7dp"
android:layout_gravity="bottom|right|end" android:layout_gravity="bottom|right|end"
app:srcCompat="@drawable/triangle_bottom_right_corner" app:srcCompat="@drawable/triangle_bottom_right_corner"
android:tint="@color/core_grey_25"/> app:tint="@color/core_grey_25"/>
</FrameLayout> </FrameLayout>

View File

@ -18,7 +18,7 @@
android:layout_marginStart="6dp" android:layout_marginStart="6dp"
android:padding="6dp" android:padding="6dp"
android:src="@drawable/ic_baseline_search_24" android:src="@drawable/ic_baseline_search_24"
android:tint="?media_keyboard_button_color" app:tint="?media_keyboard_button_color"
android:background="?selectableItemBackgroundBorderless" android:background="?selectableItemBackgroundBorderless"
android:visibility="invisible" android:visibility="invisible"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
@ -95,7 +95,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginEnd="12dp" android:layout_marginEnd="12dp"
android:scaleType="fitCenter" android:scaleType="fitCenter"
android:tint="?media_keyboard_button_color" app:tint="?media_keyboard_button_color"
android:visibility="gone" android:visibility="gone"
android:background="?selectableItemBackground" android:background="?selectableItemBackground"
app:srcCompat="@drawable/ic_baseline_add_24" app:srcCompat="@drawable/ic_baseline_add_24"

View File

@ -1,10 +1,11 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<ImageView xmlns:android="http://schemas.android.com/apk/res/android" <ImageView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/remove_image_button" android:id="@+id/remove_image_button"
android:layout_width="@dimen/media_bubble_remove_button_size" android:layout_width="@dimen/media_bubble_remove_button_size"
android:layout_height="@dimen/media_bubble_remove_button_size" android:layout_height="@dimen/media_bubble_remove_button_size"
android:layout_gravity="top|end" android:layout_gravity="top|end"
android:background="@drawable/circle_alpha" android:background="@drawable/circle_alpha"
android:src="@drawable/ic_close_white_18dp" android:src="@drawable/ic_close_white_18dp"
android:tint="#99FFFFFF" app:tint="#99FFFFFF"
android:visibility="gone" /> android:visibility="gone" />

View File

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<FrameLayout <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_marginEnd="2dp" android:layout_marginEnd="2dp"
android:layout_marginBottom="2dp"> android:layout_marginBottom="2dp">
@ -33,7 +33,7 @@
android:layout_width="20dp" android:layout_width="20dp"
android:layout_height="20dp" android:layout_height="20dp"
android:layout_marginEnd="6dp" android:layout_marginEnd="6dp"
android:tint="@android:color/white" app:tint="@android:color/white"
android:src="@drawable/ic_baseline_folder_24"/> android:src="@drawable/ic_baseline_folder_24"/>
<TextView <TextView

View File

@ -37,7 +37,7 @@
android:layout_height="18dp" android:layout_height="18dp"
android:layout_gravity="center" android:layout_gravity="center"
android:layout_marginStart="2dp" android:layout_marginStart="2dp"
android:tint="@color/core_blue" app:tint="@color/core_blue"
android:scaleType="fitXY" android:scaleType="fitXY"
app:srcCompat="@drawable/triangle_right" /> app:srcCompat="@drawable/triangle_right" />

View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<FrameLayout <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical" android:orientation="vertical"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent">
@ -47,7 +47,7 @@
android:layout_height="20dp" android:layout_height="20dp"
android:layout_marginStart="2dp" android:layout_marginStart="2dp"
android:src="@drawable/ic_arrow_right" android:src="@drawable/ic_arrow_right"
android:tint="@color/core_white"/> app:tint="@color/core_white"/>
</LinearLayout> </LinearLayout>
@ -60,7 +60,7 @@
android:layout_gravity="bottom|start" android:layout_gravity="bottom|start"
android:padding="12dp" android:padding="12dp"
android:src="@drawable/ic_camera_filled_24" android:src="@drawable/ic_camera_filled_24"
android:tint="@color/core_grey_60" app:tint="@color/core_grey_60"
android:background="@drawable/media_camera_button_background" android:background="@drawable/media_camera_button_background"
android:elevation="4dp" android:elevation="4dp"
android:visibility="gone" android:visibility="gone"

View File

@ -165,7 +165,7 @@
android:layout_width="36dp" android:layout_width="36dp"
android:layout_height="36dp" android:layout_height="36dp"
android:src="@drawable/ic_baseline_clear_24" android:src="@drawable/ic_baseline_clear_24"
android:tint="@android:color/white"/> app:tint="@android:color/white"/>
</FrameLayout> </FrameLayout>

View File

@ -133,7 +133,7 @@
android:layout_height="16dp" android:layout_height="16dp"
android:layout_marginStart="11dp" android:layout_marginStart="11dp"
android:layout_marginTop="8dp" android:layout_marginTop="8dp"
android:tint="@color/core_blue" app:tint="@color/core_blue"
android:scaleType="fitXY" android:scaleType="fitXY"
app:srcCompat="@drawable/triangle_right" /> app:srcCompat="@drawable/triangle_right" />
@ -157,7 +157,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginEnd="8dp" android:layout_marginEnd="8dp"
android:src="@drawable/ic_broken_link" android:src="@drawable/ic_broken_link"
android:tint="@color/text"/> app:tint="@color/text"/>
<TextView <TextView
android:id="@+id/quote_missing_text" android:id="@+id/quote_missing_text"

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<org.thoughtcrime.securesms.onboarding.SeedReminderView
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/seedReminderView"
android:layout_width="match_parent"
android:layout_height="wrap_content" />

View File

@ -45,7 +45,7 @@
android:layout_height="24dp" android:layout_height="24dp"
android:layout_marginStart="17dp" android:layout_marginStart="17dp"
android:layout_marginTop="12dp" android:layout_marginTop="12dp"
android:tint="@color/core_blue" app:tint="@color/core_blue"
android:scaleType="fitXY" android:scaleType="fitXY"
app:srcCompat="@drawable/triangle_right" /> app:srcCompat="@drawable/triangle_right" />

View File

@ -35,7 +35,7 @@
<ImageView <ImageView
android:layout_width="24dp" android:layout_width="24dp"
android:layout_height="24dp" android:layout_height="24dp"
android:tint="@color/core_grey_60" app:tint="@color/core_grey_60"
app:srcCompat="@drawable/ic_arrow_down_circle_filled" /> app:srcCompat="@drawable/ic_arrow_down_circle_filled" />
<TextView <TextView

View File

@ -77,10 +77,10 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_centerInParent="true" android:layout_centerInParent="true"
android:textSize="@dimen/very_small_font_size" android:text="8"
android:textColor="@color/text" android:textColor="@color/text"
android:textStyle="bold" android:textSize="@dimen/very_small_font_size"
android:text="8" /> android:textStyle="bold" />
</RelativeLayout> </RelativeLayout>

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?colorPrimary"
xmlns:tools="http://schemas.android.com/tools">
<TextView
tools:text="@string/global_search_messages"
android:textSize="18sp"
android:fontFamily="@font/roboto_medium"
android:paddingVertical="@dimen/medium_spacing"
android:paddingHorizontal="@dimen/very_large_spacing"
android:id="@+id/search_header"
android:layout_height="wrap_content"
android:layout_width="match_parent"/>
</LinearLayout>

View File

@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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:animateLayoutChanges="true"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:id="@+id/search_input_parent"
android:paddingHorizontal="8dp"
android:paddingVertical="4dp"
android:background="@drawable/search_background"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@+id/search_cancel"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="horizontal">
<ImageView
android:layout_gravity="center_vertical"
android:layout_width="16dp"
android:layout_height="16dp"
android:src="@drawable/ic_baseline_search_24"
android:scaleType="centerInside"
app:tint="?searchIconColor"
android:contentDescription="@string/SearchToolbar_search" />
<EditText
android:hint="@string/global_search_messages"
android:imeOptions="actionSearch"
android:id="@+id/search_input"
android:paddingHorizontal="@dimen/small_spacing"
android:textSize="16sp"
android:background="@null"
android:layout_weight="1"
android:layout_width="0dp"
android:inputType="text"
android:layout_height="wrap_content"/>
<ImageView
android:id="@+id/search_clear"
android:src="@drawable/ic_close_white_18dp"
android:layout_gravity="center_vertical"
android:layout_width="16dp"
android:layout_height="16dp"
app:tint="?searchIconColor" />
</LinearLayout>
<TextView
android:layout_marginStart="@dimen/small_spacing"
android:layout_centerVertical="true"
android:text="@string/cancel"
app:layout_constraintStart_toEndOf="@+id/search_input_parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:id="@+id/search_cancel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,111 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:clickable="true"
android:focusable="true"
android:background="@color/cell_background"
android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<FrameLayout
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@+id/search_result_text_parent"
android:layout_marginVertical="@dimen/small_spacing"
android:layout_marginStart="@dimen/large_spacing"
android:layout_marginEnd="@dimen/large_spacing"
android:id="@+id/search_result_profile_picture_parent"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<org.thoughtcrime.securesms.components.ProfilePictureView
android:visibility="gone"
android:id="@+id/search_result_profile_picture"
android:layout_width="@dimen/medium_profile_picture_size"
android:layout_height="@dimen/medium_profile_picture_size"
/>
<ImageView
android:background="@drawable/circle_tintable"
android:backgroundTint="?colorAccent"
android:src="@drawable/ic_outline_bookmark_border_24"
android:padding="4dp"
android:id="@+id/search_result_saved_messages"
android:layout_width="@dimen/medium_profile_picture_size"
android:layout_height="@dimen/medium_profile_picture_size"
app:tint="@color/white" />
</FrameLayout>
<LinearLayout
android:id="@+id/search_result_text_parent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/large_spacing"
app:layout_constraintStart_toEndOf="@+id/search_result_profile_picture_parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:orientation="vertical">
<LinearLayout
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:layout_gravity="center"
android:textSize="@dimen/text_size"
tools:text="@tools:sample/full_names"
android:id="@+id/search_result_title"
android:textStyle="bold"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxLines="1"
android:ellipsize="end"
/>
<RelativeLayout
android:id="@+id/unreadCountIndicator"
android:layout_width="wrap_content"
android:maxWidth="40dp"
android:paddingLeft="4dp"
android:paddingRight="4dp"
android:layout_height="20dp"
android:layout_marginStart="4dp"
android:layout_gravity="center"
android:visibility="gone"
android:background="@drawable/rounded_rectangle"
android:backgroundTint="@color/conversation_unread_count_indicator_background">
<TextView
android:id="@+id/unreadCountTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
tools:text="8"
android:textColor="@color/text"
android:textSize="@dimen/very_small_font_size"
android:textStyle="bold" />
</RelativeLayout>
<TextView
android:textColor="@color/text"
android:alpha="0.4"
android:layout_weight="1"
android:paddingStart="@dimen/small_spacing"
android:layout_gravity="end|center_vertical"
android:id="@+id/search_result_timestamp"
tools:text="@tools:sample/date/hhmmss"
android:textAlignment="viewEnd"
android:layout_width="0dp"
android:layout_height="wrap_content"/>
</LinearLayout>
<TextView
tools:text="@tools:sample/full_names"
android:textSize="@dimen/text_size"
android:id="@+id/search_result_subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/text"
android:maxLines="1"
android:ellipsize="end"
/>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item android:id="@+id/search_action"
android:icon="@drawable/ic_baseline_search_24"
app:showAsAction="ifRoom"
app:actionViewClass="androidx.appcompat.widget.SearchView"
android:title="@string/SearchToolbar_search"/>
<item android:id="@+id/path_action"
android:icon="@drawable/accent_dot"
android:title="@string/activity_path_title"
app:showAsAction="ifRoom"/>
</menu>

View File

@ -238,7 +238,7 @@ on viallinen!</string>
<string name="ThreadRecord_disappearing_messages_disabled">Katoavat viestit poistettu käytöstä</string> <string name="ThreadRecord_disappearing_messages_disabled">Katoavat viestit poistettu käytöstä</string>
<string name="ThreadRecord_disappearing_message_time_updated_to_s">Katoavien viestien ajaksi asetettu %s</string> <string name="ThreadRecord_disappearing_message_time_updated_to_s">Katoavien viestien ajaksi asetettu %s</string>
<string name="ThreadRecord_s_took_a_screenshot">%s otti kuvakaappauksen.</string> <string name="ThreadRecord_s_took_a_screenshot">%s otti kuvakaappauksen.</string>
<string name="ThreadRecord_media_saved_by_s">Media tallennettu toimesta.</string> <string name="ThreadRecord_media_saved_by_s">Media tallennettu toimesta %s.</string>
<string name="ThreadRecord_safety_number_changed">Turvanumero vaihtunut</string> <string name="ThreadRecord_safety_number_changed">Turvanumero vaihtunut</string>
<string name="ThreadRecord_your_safety_number_with_s_has_changed">Sinun ja yhteystiedon %s turvanumero on vaihtunut.</string> <string name="ThreadRecord_your_safety_number_with_s_has_changed">Sinun ja yhteystiedon %s turvanumero on vaihtunut.</string>
<string name="ThreadRecord_you_marked_verified">Merkitsit varmennetuksi.</string> <string name="ThreadRecord_you_marked_verified">Merkitsit varmennetuksi.</string>

View File

@ -238,7 +238,7 @@ on viallinen!</string>
<string name="ThreadRecord_disappearing_messages_disabled">Katoavat viestit poistettu käytöstä</string> <string name="ThreadRecord_disappearing_messages_disabled">Katoavat viestit poistettu käytöstä</string>
<string name="ThreadRecord_disappearing_message_time_updated_to_s">Katoavien viestien ajaksi asetettu %s</string> <string name="ThreadRecord_disappearing_message_time_updated_to_s">Katoavien viestien ajaksi asetettu %s</string>
<string name="ThreadRecord_s_took_a_screenshot">%s otti kuvakaappauksen.</string> <string name="ThreadRecord_s_took_a_screenshot">%s otti kuvakaappauksen.</string>
<string name="ThreadRecord_media_saved_by_s">Media tallennettu toimesta.</string> <string name="ThreadRecord_media_saved_by_s">Media tallennettu toimesta %s.</string>
<string name="ThreadRecord_safety_number_changed">Turvanumero vaihtunut</string> <string name="ThreadRecord_safety_number_changed">Turvanumero vaihtunut</string>
<string name="ThreadRecord_your_safety_number_with_s_has_changed">Sinun ja yhteystiedon %s turvanumero on vaihtunut.</string> <string name="ThreadRecord_your_safety_number_with_s_has_changed">Sinun ja yhteystiedon %s turvanumero on vaihtunut.</string>
<string name="ThreadRecord_you_marked_verified">Merkitsit varmennetuksi.</string> <string name="ThreadRecord_you_marked_verified">Merkitsit varmennetuksi.</string>

View File

@ -729,5 +729,4 @@
<string name="deleted_message">ये संदेश मिटा दिया है</string> <string name="deleted_message">ये संदेश मिटा दिया है</string>
<string name="delete_message_for_me">स्वयं के लिये मिटाये</string> <string name="delete_message_for_me">स्वयं के लिये मिटाये</string>
<string name="delete_message_for_everyone">सभी के लिए संदेश मिटायें</string> <string name="delete_message_for_everyone">सभी के लिए संदेश मिटायें</string>
<string name="delete_message_for_me_and_recipient">स्वयमेव और प्राप्तिकर्ता के लिये मिटाये</string>
</resources> </resources>

View File

@ -5,6 +5,10 @@
<item name="android:navigationBarColor">?android:navigationBarColor</item> <item name="android:navigationBarColor">?android:navigationBarColor</item>
<item name="android:textColorHint">@color/gray50</item> <item name="android:textColorHint">@color/gray50</item>
<item name="searchBackgroundColor">#F2F2F2</item>
<item name="searchIconColor">#413F40</item>
<item name="searchHighlightTint">#767676</item>
<item name="home_gradient_start">#00FFFFFF</item> <item name="home_gradient_start">#00FFFFFF</item>
<item name="home_gradient_end">#FFFFFFFF</item> <item name="home_gradient_end">#FFFFFFFF</item>

View File

@ -237,7 +237,6 @@
<string name="ThreadRecord_s_is_on_signal">%s është në Session! </string> <string name="ThreadRecord_s_is_on_signal">%s është në Session! </string>
<string name="ThreadRecord_disappearing_messages_disabled">Zhdukja e mesazheve është e çaktivizuar</string> <string name="ThreadRecord_disappearing_messages_disabled">Zhdukja e mesazheve është e çaktivizuar</string>
<string name="ThreadRecord_disappearing_message_time_updated_to_s">Koha për zhdukje mesazhesh është vënë %s</string> <string name="ThreadRecord_disappearing_message_time_updated_to_s">Koha për zhdukje mesazhesh është vënë %s</string>
<string name="ThreadRecord_s_took_a_screenshot">Bëri nje screenshot</string>
<string name="ThreadRecord_media_saved_by_s">Media u kursye me %s</string> <string name="ThreadRecord_media_saved_by_s">Media u kursye me %s</string>
<string name="ThreadRecord_safety_number_changed">Numri i sigurisë ndryshoi</string> <string name="ThreadRecord_safety_number_changed">Numri i sigurisë ndryshoi</string>
<string name="ThreadRecord_your_safety_number_with_s_has_changed">Numri juaj i sigurisë me %s është ndryshuar.</string> <string name="ThreadRecord_your_safety_number_with_s_has_changed">Numri juaj i sigurisë me %s është ndryshuar.</string>

View File

@ -8,7 +8,7 @@
<color name="profile_picture_background">#353535</color> <color name="profile_picture_background">#353535</color>
<color name="cell_background">#1B1B1B</color> <color name="cell_background">#1B1B1B</color>
<color name="cell_selected">#0C0C0C</color> <color name="cell_selected">#0C0C0C</color>
<color name="action_bar_background">#171717</color> <color name="action_bar_background">#161616</color>
<color name="separator">#36383C</color> <color name="separator">#36383C</color>
<color name="unimportant_button_background">#323232</color> <color name="unimportant_button_background">#323232</color>
<color name="dialog_background">#101011</color> <color name="dialog_background">#101011</color>

View File

@ -905,5 +905,7 @@
<string name="conversation_pin">Pin</string> <string name="conversation_pin">Pin</string>
<string name="conversation_unpin">Unpin</string> <string name="conversation_unpin">Unpin</string>
<string name="mark_all_as_read">Mark all as read</string> <string name="mark_all_as_read">Mark all as read</string>
<string name="global_search_contacts_groups">Contacts and Groups</string>
<string name="global_search_messages">Messages</string>
</resources> </resources>

View File

@ -5,6 +5,9 @@
<style name="Base.Theme.Session" parent="@style/Theme.AppCompat.DayNight.DarkActionBar"> <style name="Base.Theme.Session" parent="@style/Theme.AppCompat.DayNight.DarkActionBar">
<item name="colorPrimary">@color/action_bar_background</item> <item name="colorPrimary">@color/action_bar_background</item>
<item name="colorPrimaryDark">@color/action_bar_background</item> <item name="colorPrimaryDark">@color/action_bar_background</item>
<item name="searchBackgroundColor">#1B1B1B</item>
<item name="searchIconColor">#E5E5E8</item>
<item name="searchHighlightTint">@color/accent</item>
<item name="colorAccent">@color/accent</item> <item name="colorAccent">@color/accent</item>
<item name="colorControlNormal">?android:textColorPrimary</item> <item name="colorControlNormal">?android:textColorPrimary</item>
<item name="colorControlActivated">?colorAccent</item> <item name="colorControlActivated">?colorAccent</item>

View File

@ -2,4 +2,7 @@
<resources> <resources>
<bool name="enable_alarm_manager">true</bool> <bool name="enable_alarm_manager">true</bool>
<bool name="enable_job_service">false</bool> <bool name="enable_job_service">false</bool>
<attr name="searchBackgroundColor" format="color"/>
<attr name="searchIconColor" format="color"/>
<attr name="searchHighlightTint" format="color"/>
</resources> </resources>

View File

@ -1,78 +0,0 @@
package org.thoughtcrime.securesms;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.Handler;
import android.os.Looper;
import android.preference.PreferenceManager;
import android.text.TextUtils;
import org.session.libsignal.utilities.Log;
import org.junit.Before;
import org.junit.runner.RunWith;
import org.mockito.invocation.InvocationOnMock;
import org.mockito.stubbing.Answer;
import org.powermock.api.mockito.PowerMockito;
import org.powermock.core.classloader.annotations.PrepareForTest;
import org.powermock.modules.junit4.PowerMockRunner;
import static org.mockito.Matchers.any;
import static org.mockito.Matchers.anyBoolean;
import static org.mockito.Matchers.anyFloat;
import static org.mockito.Matchers.anyInt;
import static org.mockito.Matchers.anyLong;
import static org.mockito.Matchers.anyString;
import static org.powermock.api.mockito.PowerMockito.mock;
import static org.powermock.api.mockito.PowerMockito.mockStatic;
import static org.powermock.api.mockito.PowerMockito.when;
@RunWith(PowerMockRunner.class)
@PrepareForTest({ Log.class, Handler.class, Looper.class, TextUtils.class, PreferenceManager.class })
public abstract class BaseUnitTest {
protected Context context = mock(Context.class);
protected SharedPreferences sharedPreferences = mock(SharedPreferences.class);
@Before
public void setUp() throws Exception {
mockStatic(Looper.class);
mockStatic(Log.class);
mockStatic(Handler.class);
mockStatic(TextUtils.class);
mockStatic(PreferenceManager.class);
when(PreferenceManager.getDefaultSharedPreferences(any(Context.class))).thenReturn(sharedPreferences);
when(Looper.getMainLooper()).thenReturn(null);
PowerMockito.whenNew(Handler.class).withAnyArguments().thenReturn(null);
Answer<?> logAnswer = new Answer<Void>() {
@Override public Void answer(InvocationOnMock invocation) throws Throwable {
final String tag = (String)invocation.getArguments()[0];
final String msg = (String)invocation.getArguments()[1];
System.out.println(invocation.getMethod().getName().toUpperCase() + "/[" + tag + "] " + msg);
return null;
}
};
PowerMockito.doAnswer(logAnswer).when(Log.class, "d", anyString(), anyString());
PowerMockito.doAnswer(logAnswer).when(Log.class, "i", anyString(), anyString());
PowerMockito.doAnswer(logAnswer).when(Log.class, "w", anyString(), anyString());
PowerMockito.doAnswer(logAnswer).when(Log.class, "e", anyString(), anyString());
PowerMockito.doAnswer(logAnswer).when(Log.class, "wtf", anyString(), anyString());
PowerMockito.doAnswer(new Answer<Boolean>() {
@Override
public Boolean answer(InvocationOnMock invocation) throws Throwable {
final String s = (String)invocation.getArguments()[0];
return s == null || s.length() == 0;
}
}).when(TextUtils.class, "isEmpty", anyString());
when(sharedPreferences.getString(anyString(), anyString())).thenReturn("");
when(sharedPreferences.getLong(anyString(), anyLong())).thenReturn(0L);
when(sharedPreferences.getInt(anyString(), anyInt())).thenReturn(0);
when(sharedPreferences.getBoolean(anyString(), anyBoolean())).thenReturn(false);
when(sharedPreferences.getFloat(anyString(), anyFloat())).thenReturn(0f);
when(context.getSharedPreferences(anyString(), anyInt())).thenReturn(sharedPreferences);
when(context.getPackageName()).thenReturn("org.thoughtcrime.securesms");
}
}

View File

@ -11,7 +11,7 @@ import org.junit.Before;
import org.junit.Test; import org.junit.Test;
import static org.junit.Assert.*; import static org.junit.Assert.*;
import static org.mockito.Matchers.anyInt; import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;

View File

@ -8,6 +8,8 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import android.app.Application;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import com.annimon.stream.Stream; import com.annimon.stream.Stream;
@ -84,13 +86,14 @@ public class FastJobStorageTest {
@Test @Test
public void updateAllJobsToBePending_allArePending() { public void updateAllJobsToBePending_allArePending() {
FullSpec fullSpec1 = new FullSpec(new JobSpec("1", "f1", null, 1, 1, 1, 1, 1, 1, 1, EMPTY_DATA, true), FullSpec fullSpec1 = new FullSpec(new JobSpec("1", AvatarDownloadJob.KEY, null, 1, 1, 1, 1, 1, 1, 1, EMPTY_DATA, true),
Collections.emptyList(), Collections.emptyList(),
Collections.emptyList()); Collections.emptyList());
FullSpec fullSpec2 = new FullSpec(new JobSpec("2", "f2", null, 1, 1, 1, 1, 1, 1, 1, EMPTY_DATA, true), FullSpec fullSpec2 = new FullSpec(new JobSpec("2", AvatarDownloadJob.KEY, null, 1, 1, 1, 1, 1, 1, 1, EMPTY_DATA, true),
Collections.emptyList(), Collections.emptyList(),
Collections.emptyList()); Collections.emptyList());
JobManagerFactories.getJobFactories(mock(Application.class));
FastJobStorage subject = new FastJobStorage(fixedDataDatabase(Arrays.asList(fullSpec1, fullSpec2))); FastJobStorage subject = new FastJobStorage(fixedDataDatabase(Arrays.asList(fullSpec1, fullSpec2)));
subject.init(); subject.init();
@ -134,10 +137,11 @@ public class FastJobStorageTest {
@Test @Test
public void updateJobAfterRetry_stateUpdated() { public void updateJobAfterRetry_stateUpdated() {
FullSpec fullSpec = new FullSpec(new JobSpec("1", "f1", null, 0, 0, 0, 3, 30000, -1, -1, EMPTY_DATA, true), FullSpec fullSpec = new FullSpec(new JobSpec("1", AvatarDownloadJob.KEY, null, 0, 0, 0, 3, 30000, -1, -1, EMPTY_DATA, true),
Collections.emptyList(), Collections.emptyList(),
Collections.emptyList()); Collections.emptyList());
JobManagerFactories.getJobFactories(mock(Application.class));
FastJobStorage subject = new FastJobStorage(fixedDataDatabase(Collections.singletonList(fullSpec))); FastJobStorage subject = new FastJobStorage(fixedDataDatabase(Collections.singletonList(fullSpec)));
subject.init(); subject.init();
@ -153,13 +157,14 @@ public class FastJobStorageTest {
@Test @Test
public void getPendingJobsWithNoDependenciesInCreatedOrder_noneWhenEarlierItemInQueueInRunning() { public void getPendingJobsWithNoDependenciesInCreatedOrder_noneWhenEarlierItemInQueueInRunning() {
FullSpec fullSpec1 = new FullSpec(new JobSpec("1", "f1", "q", 0, 0, 0, 0, 0, -1, -1, EMPTY_DATA, true), FullSpec fullSpec1 = new FullSpec(new JobSpec("1", AvatarDownloadJob.KEY, "q", 0, 0, 0, 0, 0, -1, -1, EMPTY_DATA, true),
Collections.emptyList(), Collections.emptyList(),
Collections.emptyList()); Collections.emptyList());
FullSpec fullSpec2 = new FullSpec(new JobSpec("2", "f2", "q", 0, 0, 0, 0, 0, -1, -1, EMPTY_DATA, false), FullSpec fullSpec2 = new FullSpec(new JobSpec("2", AvatarDownloadJob.KEY, "q", 0, 0, 0, 0, 0, -1, -1, EMPTY_DATA, false),
Collections.emptyList(), Collections.emptyList(),
Collections.emptyList()); Collections.emptyList());
JobManagerFactories.getJobFactories(mock(Application.class));
FastJobStorage subject = new FastJobStorage(fixedDataDatabase(Arrays.asList(fullSpec1, fullSpec2))); FastJobStorage subject = new FastJobStorage(fixedDataDatabase(Arrays.asList(fullSpec1, fullSpec2)));
subject.init(); subject.init();
@ -168,10 +173,11 @@ public class FastJobStorageTest {
@Test @Test
public void getPendingJobsWithNoDependenciesInCreatedOrder_noneWhenAllJobsAreRunning() { public void getPendingJobsWithNoDependenciesInCreatedOrder_noneWhenAllJobsAreRunning() {
FullSpec fullSpec = new FullSpec(new JobSpec("1", "f1", "q", 0, 0, 0, 0, 0, -1, -1, EMPTY_DATA, true), FullSpec fullSpec = new FullSpec(new JobSpec("1", AvatarDownloadJob.KEY, "q", 0, 0, 0, 0, 0, -1, -1, EMPTY_DATA, true),
Collections.emptyList(), Collections.emptyList(),
Collections.emptyList()); Collections.emptyList());
JobManagerFactories.getJobFactories(mock(Application.class));
FastJobStorage subject = new FastJobStorage(fixedDataDatabase(Collections.singletonList(fullSpec))); FastJobStorage subject = new FastJobStorage(fixedDataDatabase(Collections.singletonList(fullSpec)));
subject.init(); subject.init();
@ -180,10 +186,11 @@ public class FastJobStorageTest {
@Test @Test
public void getPendingJobsWithNoDependenciesInCreatedOrder_noneWhenNextRunTimeIsAfterCurrentTime() { public void getPendingJobsWithNoDependenciesInCreatedOrder_noneWhenNextRunTimeIsAfterCurrentTime() {
FullSpec fullSpec = new FullSpec(new JobSpec("1", "f1", "q", 0, 10, 0, 0, 0, -1, -1, EMPTY_DATA, false), FullSpec fullSpec = new FullSpec(new JobSpec("1", AvatarDownloadJob.KEY, "q", 0, 10, 0, 0, 0, -1, -1, EMPTY_DATA, false),
Collections.emptyList(), Collections.emptyList(),
Collections.emptyList()); Collections.emptyList());
JobManagerFactories.getJobFactories(mock(Application.class));
FastJobStorage subject = new FastJobStorage(fixedDataDatabase(Collections.singletonList(fullSpec))); FastJobStorage subject = new FastJobStorage(fixedDataDatabase(Collections.singletonList(fullSpec)));
subject.init(); subject.init();
@ -192,14 +199,14 @@ public class FastJobStorageTest {
@Test @Test
public void getPendingJobsWithNoDependenciesInCreatedOrder_noneWhenDependentOnAnotherJob() { public void getPendingJobsWithNoDependenciesInCreatedOrder_noneWhenDependentOnAnotherJob() {
FullSpec fullSpec1 = new FullSpec(new JobSpec("1", "f1", null, 0, 0, 0, 0, 0, -1, -1, EMPTY_DATA, true), FullSpec fullSpec1 = new FullSpec(new JobSpec("1", AvatarDownloadJob.KEY, null, 0, 0, 0, 0, 0, -1, -1, EMPTY_DATA, true),
Collections.emptyList(), Collections.emptyList(),
Collections.emptyList()); Collections.emptyList());
FullSpec fullSpec2 = new FullSpec(new JobSpec("2", "f2", null, 0, 0, 0, 0, 0, -1, -1, EMPTY_DATA, false), FullSpec fullSpec2 = new FullSpec(new JobSpec("2", AvatarDownloadJob.KEY, null, 0, 0, 0, 0, 0, -1, -1, EMPTY_DATA, false),
Collections.emptyList(), Collections.emptyList(),
Collections.singletonList(new DependencySpec("2", "1"))); Collections.singletonList(new DependencySpec("2", "1")));
JobManagerFactories.getJobFactories(mock(Application.class));
FastJobStorage subject = new FastJobStorage(fixedDataDatabase(Arrays.asList(fullSpec1, fullSpec2))); FastJobStorage subject = new FastJobStorage(fixedDataDatabase(Arrays.asList(fullSpec1, fullSpec2)));
subject.init(); subject.init();
@ -208,10 +215,11 @@ public class FastJobStorageTest {
@Test @Test
public void getPendingJobsWithNoDependenciesInCreatedOrder_singleEligibleJob() { public void getPendingJobsWithNoDependenciesInCreatedOrder_singleEligibleJob() {
FullSpec fullSpec = new FullSpec(new JobSpec("1", "f1", "q", 0, 0, 0, 0, 0, -1, -1, EMPTY_DATA, false), FullSpec fullSpec = new FullSpec(new JobSpec("1", AvatarDownloadJob.KEY, "q", 0, 0, 0, 0, 0, -1, -1, EMPTY_DATA, false),
Collections.emptyList(), Collections.emptyList(),
Collections.emptyList()); Collections.emptyList());
JobManagerFactories.getJobFactories(mock(Application.class));
FastJobStorage subject = new FastJobStorage(fixedDataDatabase(Collections.singletonList(fullSpec))); FastJobStorage subject = new FastJobStorage(fixedDataDatabase(Collections.singletonList(fullSpec)));
subject.init(); subject.init();
@ -220,14 +228,14 @@ public class FastJobStorageTest {
@Test @Test
public void getPendingJobsWithNoDependenciesInCreatedOrder_multipleEligibleJobs() { public void getPendingJobsWithNoDependenciesInCreatedOrder_multipleEligibleJobs() {
FullSpec fullSpec1 = new FullSpec(new JobSpec("1", "f1", null, 0, 0, 0, 0, 0, -1, -1, EMPTY_DATA, false), FullSpec fullSpec1 = new FullSpec(new JobSpec("1", AvatarDownloadJob.KEY, null, 0, 0, 0, 0, 0, -1, -1, EMPTY_DATA, false),
Collections.emptyList(), Collections.emptyList(),
Collections.emptyList()); Collections.emptyList());
FullSpec fullSpec2 = new FullSpec(new JobSpec("2", "f2", null, 0, 0, 0, 0, 0, -1, -1, EMPTY_DATA, false), FullSpec fullSpec2 = new FullSpec(new JobSpec("2", AvatarDownloadJob.KEY, null, 0, 0, 0, 0, 0, -1, -1, EMPTY_DATA, false),
Collections.emptyList(), Collections.emptyList(),
Collections.emptyList()); Collections.emptyList());
JobManagerFactories.getJobFactories(mock(Application.class));
FastJobStorage subject = new FastJobStorage(fixedDataDatabase(Arrays.asList(fullSpec1, fullSpec2))); FastJobStorage subject = new FastJobStorage(fixedDataDatabase(Arrays.asList(fullSpec1, fullSpec2)));
subject.init(); subject.init();
@ -236,14 +244,14 @@ public class FastJobStorageTest {
@Test @Test
public void getPendingJobsWithNoDependenciesInCreatedOrder_singleEligibleJobInMixedList() { public void getPendingJobsWithNoDependenciesInCreatedOrder_singleEligibleJobInMixedList() {
FullSpec fullSpec1 = new FullSpec(new JobSpec("1", "f1", null, 0, 0, 0, 0, 0, -1, -1, EMPTY_DATA, true), FullSpec fullSpec1 = new FullSpec(new JobSpec("1", AvatarDownloadJob.KEY, null, 0, 0, 0, 0, 0, -1, -1, EMPTY_DATA, true),
Collections.emptyList(), Collections.emptyList(),
Collections.emptyList()); Collections.emptyList());
FullSpec fullSpec2 = new FullSpec(new JobSpec("2", "f2", null, 0, 0, 0, 0, 0, -1, -1, EMPTY_DATA, false), FullSpec fullSpec2 = new FullSpec(new JobSpec("2", AvatarDownloadJob.KEY, null, 0, 0, 0, 0, 0, -1, -1, EMPTY_DATA, false),
Collections.emptyList(), Collections.emptyList(),
Collections.emptyList()); Collections.emptyList());
JobManagerFactories.getJobFactories(mock(Application.class));
FastJobStorage subject = new FastJobStorage(fixedDataDatabase(Arrays.asList(fullSpec1, fullSpec2))); FastJobStorage subject = new FastJobStorage(fixedDataDatabase(Arrays.asList(fullSpec1, fullSpec2)));
subject.init(); subject.init();
@ -255,14 +263,14 @@ public class FastJobStorageTest {
@Test @Test
public void getPendingJobsWithNoDependenciesInCreatedOrder_firstItemInQueue() { public void getPendingJobsWithNoDependenciesInCreatedOrder_firstItemInQueue() {
FullSpec fullSpec1 = new FullSpec(new JobSpec("1", "f1", "q", 0, 0, 0, 0, 0, -1, -1, EMPTY_DATA, false), FullSpec fullSpec1 = new FullSpec(new JobSpec("1", RetrieveProfileAvatarJob.KEY, "q", 0, 0, 0, 0, 0, -1, -1, EMPTY_DATA, false),
Collections.emptyList(), Collections.emptyList(),
Collections.emptyList()); Collections.emptyList());
FullSpec fullSpec2 = new FullSpec(new JobSpec("2", "f2", "q", 0, 0, 0, 0, 0, -1, -1, EMPTY_DATA, false), FullSpec fullSpec2 = new FullSpec(new JobSpec("2", RetrieveProfileAvatarJob.KEY, "q", 0, 0, 0, 0, 0, -1, -1, EMPTY_DATA, false),
Collections.emptyList(), Collections.emptyList(),
Collections.emptyList()); Collections.emptyList());
JobManagerFactories.getJobFactories(mock(Application.class));
FastJobStorage subject = new FastJobStorage(fixedDataDatabase(Arrays.asList(fullSpec1, fullSpec2))); FastJobStorage subject = new FastJobStorage(fixedDataDatabase(Arrays.asList(fullSpec1, fullSpec2)));
subject.init(); subject.init();

View File

@ -1,7 +1,6 @@
package org.thoughtcrime.securesms.util; package org.thoughtcrime.securesms.util;
import org.junit.Test; import org.junit.Test;
import org.thoughtcrime.securesms.BaseUnitTest;
import org.session.libsession.utilities.Util; import org.session.libsession.utilities.Util;
import java.util.LinkedList; import java.util.LinkedList;
@ -9,7 +8,7 @@ import java.util.List;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
public class ListPartitionTest extends BaseUnitTest { public class ListPartitionTest {
@Test public void testPartitionEven() { @Test public void testPartitionEven() {
List<Integer> list = new LinkedList<>(); List<Integer> list = new LinkedList<>();

View File

@ -20,13 +20,12 @@ package org.thoughtcrime.securesms.util;
import junit.framework.AssertionFailedError; import junit.framework.AssertionFailedError;
import org.junit.Test; import org.junit.Test;
import org.thoughtcrime.securesms.BaseUnitTest;
import java.net.URISyntaxException; import java.net.URISyntaxException;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
public class Rfc5724UriTest extends BaseUnitTest { public class Rfc5724UriTest {
@Test public void testInvalidPath() throws Exception { @Test public void testInvalidPath() throws Exception {
final String[] invalidSchemaUris = { final String[] invalidSchemaUris = {

View File

@ -1,6 +1,6 @@
android.useAndroidX=true android.useAndroidX=true
android.enableJetifier=true android.enableJetifier=true
org.gradle.jvmargs=-Xmx2048m org.gradle.jvmargs=-Xmx4g
kotlinVersion=1.6.0 kotlinVersion=1.6.0
coroutinesVersion=1.6.0 coroutinesVersion=1.6.0

View File

@ -42,8 +42,17 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion" implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion"
implementation "nl.komponents.kovenant:kovenant:$kovenantVersion" implementation "nl.komponents.kovenant:kovenant:$kovenantVersion"
testImplementation "junit:junit:3.8.2" testImplementation 'junit:junit:4.12'
testImplementation "org.assertj:assertj-core:1.7.1" testImplementation 'org.assertj:assertj-core:3.11.1'
testImplementation "org.mockito:mockito-inline:4.0.0"
testImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion"
testImplementation 'org.powermock:powermock-api-mockito:1.6.1'
testImplementation 'org.powermock:powermock-module-junit4:1.6.1'
testImplementation 'org.powermock:powermock-module-junit4-rule:1.6.1'
testImplementation 'org.powermock:powermock-classloading-xstream:1.6.1'
testImplementation 'androidx.test:core:1.3.0'
testImplementation "androidx.arch.core:core-testing:2.1.0"
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"
testImplementation "org.conscrypt:conscrypt-openjdk-uber:2.0.0" testImplementation "org.conscrypt:conscrypt-openjdk-uber:2.0.0"
implementation 'org.greenrobot:eventbus:3.0.0' implementation 'org.greenrobot:eventbus:3.0.0'
} }

View File

@ -45,7 +45,7 @@ class BatchMessageReceiveJob(
fun executeAsync(): Promise<Unit, Exception> { fun executeAsync(): Promise<Unit, Exception> {
return task { return task {
messages.forEach { messageParameters -> messages.iterator().forEach { messageParameters ->
val (data, serverHash, openGroupMessageServerID) = messageParameters val (data, serverHash, openGroupMessageServerID) = messageParameters
try { try {
val (message, proto) = MessageReceiver.parse(data, openGroupMessageServerID) val (message, proto) = MessageReceiver.parse(data, openGroupMessageServerID)

View File

@ -155,7 +155,7 @@ object MessageSender {
var isSuccess = false var isSuccess = false
val promiseCount = promises.size val promiseCount = promises.size
var errorCount = 0 var errorCount = 0
promises.forEach { promise: RawResponsePromise -> promises.iterator().forEach { promise: RawResponsePromise ->
promise.success { promise.success {
if (isSuccess) { return@success } // Succeed as soon as the first promise succeeds if (isSuccess) { return@success } // Succeed as soon as the first promise succeeds
isSuccess = true isSuccess = true

View File

@ -236,7 +236,7 @@ fun MessageReceiver.handleVisibleMessage(message: VisibleMessage, proto: SignalS
val messageID = storage.persist(message, quoteModel, linkPreviews, message.groupPublicKey, openGroupID, attachments) ?: throw MessageReceiver.Error.DuplicateMessage val messageID = storage.persist(message, quoteModel, linkPreviews, message.groupPublicKey, openGroupID, attachments) ?: throw MessageReceiver.Error.DuplicateMessage
// Parse & persist attachments // Parse & persist attachments
// Start attachment downloads if needed // Start attachment downloads if needed
storage.getAttachmentsForMessage(messageID).forEach { attachment -> storage.getAttachmentsForMessage(messageID).iterator().forEach { attachment ->
attachment.attachmentId?.let { id -> attachment.attachmentId?.let { id ->
val downloadJob = AttachmentDownloadJob(id.rowId, messageID) val downloadJob = AttachmentDownloadJob(id.rowId, messageID)
JobQueue.shared.add(downloadJob) JobQueue.shared.add(downloadJob)

View File

@ -52,7 +52,7 @@ object PushNotificationAPI {
// Unsubscribe from all closed groups // Unsubscribe from all closed groups
val allClosedGroupPublicKeys = MessagingModuleConfiguration.shared.storage.getAllClosedGroupPublicKeys() val allClosedGroupPublicKeys = MessagingModuleConfiguration.shared.storage.getAllClosedGroupPublicKeys()
val userPublicKey = MessagingModuleConfiguration.shared.storage.getUserPublicKey()!! val userPublicKey = MessagingModuleConfiguration.shared.storage.getUserPublicKey()!!
allClosedGroupPublicKeys.forEach { closedGroup -> allClosedGroupPublicKeys.iterator().forEach { closedGroup ->
performOperation(ClosedGroupOperation.Unsubscribe, closedGroup, userPublicKey) performOperation(ClosedGroupOperation.Unsubscribe, closedGroup, userPublicKey)
} }
} }
@ -81,7 +81,7 @@ object PushNotificationAPI {
} }
// Subscribe to all closed groups // Subscribe to all closed groups
val allClosedGroupPublicKeys = MessagingModuleConfiguration.shared.storage.getAllClosedGroupPublicKeys() val allClosedGroupPublicKeys = MessagingModuleConfiguration.shared.storage.getAllClosedGroupPublicKeys()
allClosedGroupPublicKeys.forEach { closedGroup -> allClosedGroupPublicKeys.iterator().forEach { closedGroup ->
performOperation(ClosedGroupOperation.Subscribe, closedGroup, publicKey) performOperation(ClosedGroupOperation.Subscribe, closedGroup, publicKey)
} }
} }

View File

@ -39,7 +39,7 @@ class ClosedGroupPollerV2 {
fun start() { fun start() {
val storage = MessagingModuleConfiguration.shared.storage val storage = MessagingModuleConfiguration.shared.storage
val allGroupPublicKeys = storage.getAllClosedGroupPublicKeys() val allGroupPublicKeys = storage.getAllClosedGroupPublicKeys()
allGroupPublicKeys.forEach { startPolling(it) } allGroupPublicKeys.iterator().forEach { startPolling(it) }
} }
fun startPolling(groupPublicKey: String) { fun startPolling(groupPublicKey: String) {
@ -51,7 +51,7 @@ class ClosedGroupPollerV2 {
fun stop() { fun stop() {
val storage = MessagingModuleConfiguration.shared.storage val storage = MessagingModuleConfiguration.shared.storage
val allGroupPublicKeys = storage.getAllClosedGroupPublicKeys() val allGroupPublicKeys = storage.getAllClosedGroupPublicKeys()
allGroupPublicKeys.forEach { stopPolling(it) } allGroupPublicKeys.iterator().forEach { stopPolling(it) }
} }
fun stopPolling(groupPublicKey: String) { fun stopPolling(groupPublicKey: String) {
@ -100,7 +100,7 @@ class ClosedGroupPollerV2 {
} }
promise.success { envelopes -> promise.success { envelopes ->
if (!isPolling(groupPublicKey)) { return@success } if (!isPolling(groupPublicKey)) { return@success }
envelopes.forEach { (envelope, serverHash) -> envelopes.iterator().forEach { (envelope, serverHash) ->
val job = MessageReceiveJob(envelope.toByteArray(), serverHash) val job = MessageReceiveJob(envelope.toByteArray(), serverHash)
JobQueue.shared.add(job) JobQueue.shared.add(job)
} }

View File

@ -22,6 +22,7 @@ import java.util.concurrent.CountDownLatch
import java.util.concurrent.ExecutorService import java.util.concurrent.ExecutorService
import java.util.concurrent.ThreadPoolExecutor import java.util.concurrent.ThreadPoolExecutor
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.math.min
object Util { object Util {
@Volatile @Volatile
@ -216,7 +217,7 @@ object Util {
val results: MutableList<List<T>> = LinkedList() val results: MutableList<List<T>> = LinkedList()
var index = 0 var index = 0
while (index < list.size) { while (index < list.size) {
val subListSize = Math.min(partitionSize, list.size - index) val subListSize = min(partitionSize, list.size - index)
results.add(list.subList(index, index + subListSize)) results.add(list.subList(index, index + subListSize))
index += partitionSize index += partitionSize
} }

View File

@ -2,7 +2,6 @@
<resources> <resources>
<string-array name="language_entries"> <string-array name="language_entries">
<item>@string/preferences__default</item>
<item>English</item> <item>English</item>
<item>Arabic العربية</item> <item>Arabic العربية</item>
<item>Azərbaycan</item> <item>Azərbaycan</item>
@ -119,18 +118,6 @@
<item>vi</item> <item>vi</item>
</string-array> </string-array>
<string-array name="pref_led_color_entries">
<item>@string/preferences__green</item>
<item>@string/preferences__red</item>
<item>@string/preferences__blue</item>
<item>@string/preferences__orange</item>
<item>@string/preferences__cyan</item>
<item>@string/preferences__magenta</item>
<item>@string/preferences__white</item>
<item>@string/preferences__none</item>
</string-array>
<string-array name="pref_led_color_values" translatable="false"> <string-array name="pref_led_color_values" translatable="false">
<item>green</item> <item>green</item>
<item>red</item> <item>red</item>
@ -142,27 +129,12 @@
<item>none</item> <item>none</item>
</string-array> </string-array>
<string-array name="pref_led_blink_pattern_entries">
<item>@string/preferences__fast</item>
<item>@string/preferences__normal</item>
<item>@string/preferences__slow</item>
</string-array>
<string-array name="pref_led_blink_pattern_values" translatable="false"> <string-array name="pref_led_blink_pattern_values" translatable="false">
<item>300,300</item> <item>300,300</item>
<item>500,2000</item> <item>500,2000</item>
<item>3000,3000</item> <item>3000,3000</item>
</string-array> </string-array>
<string-array name="pref_repeat_alerts_entries">
<item>@string/preferences__never</item>
<item>@string/preferences__one_time</item>
<item>@string/preferences__two_times</item>
<item>@string/preferences__three_times</item>
<item>@string/preferences__five_times</item>
<item>@string/preferences__ten_times</item>
</string-array>
<string-array name="pref_repeat_alerts_values" translatable="false"> <string-array name="pref_repeat_alerts_values" translatable="false">
<item>0</item> <item>0</item>
<item>1</item> <item>1</item>
@ -177,25 +149,6 @@
<item>custom</item> <item>custom</item>
</string-array> </string-array>
<string-array name="default_or_custom_entries">
<item>@string/arrays__use_default</item>
<item>@string/arrays__use_custom</item>
</string-array>
<string-array name="mute_durations">
<item>@string/arrays__mute_for_one_hour</item>
<item>@string/arrays__mute_for_two_hours</item>
<item>@string/arrays__mute_for_one_day</item>
<item>@string/arrays__mute_for_seven_days</item>
<item>@string/arrays__mute_for_one_year</item>
</string-array>
<string-array name="pref_notification_privacy_entries">
<item>@string/arrays__name_and_message</item>
<item>@string/arrays__name_only</item>
<item>@string/arrays__no_name_or_message</item>
</string-array>
<string-array name="pref_notification_privacy_values"> <string-array name="pref_notification_privacy_values">
<item>all</item> <item>all</item>
<item>contact</item> <item>contact</item>
@ -247,12 +200,6 @@
<item>#000000</item> <item>#000000</item>
</array> </array>
<string-array name="pref_notification_priority_entries">
<item>@string/arrays__default</item>
<item>@string/arrays__high</item>
<item>@string/arrays__max</item>
</string-array>
<string-array name="pref_notification_priority_values"> <string-array name="pref_notification_priority_values">
<item>0</item> <item>0</item>
<item>1</item> <item>1</item>

View File

@ -1,17 +0,0 @@
package org.session.libsignal
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}