diff --git a/app/build.gradle b/app/build.gradle index 4e91ee123..40a9e4f0e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -130,6 +130,7 @@ dependencies { testImplementation 'androidx.test:core:1.3.0' testImplementation "androidx.arch.core:core-testing:2.1.0" testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion" + androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion" // Core library androidTestImplementation 'androidx.test:core:1.4.0' @@ -156,7 +157,7 @@ dependencies { testImplementation 'org.robolectric:shadows-multidex:4.4' } -def canonicalVersionCode = 242 +def canonicalVersionCode = 248 def canonicalVersionName = "1.11.14" def postFixSize = 10 diff --git a/app/src/androidTest/AndroidManifest.xml b/app/src/androidTest/AndroidManifest.xml index 68f81f6f8..deab87dd6 100644 --- a/app/src/androidTest/AndroidManifest.xml +++ b/app/src/androidTest/AndroidManifest.xml @@ -1,6 +1,6 @@ + package="network.loki.messenger.test"> diff --git a/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt b/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt index 46db01b13..e59b49669 100644 --- a/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt +++ b/app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt @@ -15,6 +15,7 @@ import androidx.test.platform.app.InstrumentationRegistry import network.loki.messenger.util.InputBarButtonDrawableMatcher.Companion.inputButtonWithDrawable import network.loki.messenger.util.NewConversationButtonDrawableMatcher.Companion.newConversationButtonWithDrawable import org.hamcrest.Matchers.allOf +import org.hamcrest.Matchers.not import org.junit.Rule import org.junit.Test 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(withId(R.id.copyButton)).perform(ViewActions.click()) pressBack() - onView(withId(R.id.seedReminderView)).check(matches(withEffectiveVisibility(Visibility.GONE))) + onView(withId(R.id.seedReminderView)).check(matches(not(isDisplayed()))) } @Test @@ -85,7 +86,7 @@ class HomeActivityTests { @Test fun testIsVisible_alreadyDismissed_seedView() { setupLoggedInState(hasViewedSeed = true) - onView(withId(R.id.seedReminderView)).check(doesNotExist()) + onView(withId(R.id.seedReminderView)).check(matches(not(isDisplayed()))) } @Test diff --git a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioCodec.java b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioCodec.java index ff3b0b809..699e9ba97 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioCodec.java +++ b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioCodec.java @@ -35,12 +35,10 @@ public class AudioCodec { public AudioCodec() throws IOException { 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.start(); - try { + this.audioRecord = createAudioRecord(this.bufferSize); + this.mediaCodec.start(); audioRecord.startRecording(); } catch (Exception e) { Log.w(TAG, e); @@ -167,7 +165,7 @@ public class AudioCodec { return adtsHeader; } - private AudioRecord createAudioRecord(int bufferSize) { + private AudioRecord createAudioRecord(int bufferSize) throws SecurityException { return new AudioRecord(MediaRecorder.AudioSource.MIC, SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, bufferSize * 10); diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactAccessor.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactAccessor.java index ecd94cff4..d560247fb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactAccessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactAccessor.java @@ -66,25 +66,18 @@ public class ContactAccessor { public List getNumbersForThreadSearchFilter(Context context, String constraint) { LinkedList numberList = new LinkedList<>(); - GroupDatabase.Reader reader = null; GroupRecord record; - - try { - reader = DatabaseComponent.get(context).groupDatabase().getGroupsFilteredByTitle(constraint); - + try (GroupDatabase.Reader reader = DatabaseComponent.get(context).groupDatabase().getGroupsFilteredByTitle(constraint)) { while ((record = reader.getNext()) != null) { numberList.add(record.getEncodedId()); } - } finally { - if (reader != null) - reader.close(); } - if (context.getString(R.string.note_to_self).toLowerCase().contains(constraint.toLowerCase()) && - !numberList.contains(TextSecurePreferences.getLocalNumber(context))) - { - numberList.add(TextSecurePreferences.getLocalNumber(context)); - } +// if (context.getString(R.string.note_to_self).toLowerCase().contains(constraint.toLowerCase()) && +// !numberList.contains(TextSecurePreferences.getLocalNumber(context))) +// { +// numberList.add(TextSecurePreferences.getLocalNumber(context)); +// } return numberList; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index a41a2c373..fc181d79c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -133,6 +133,8 @@ import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.util.toPx import java.util.Locale import java.util.concurrent.ExecutionException +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.atomic.AtomicReference import javax.inject.Inject import kotlin.math.abs 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 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 messageToScrollTimestamp = AtomicLong(-1) + private val messageToScrollAuthor = AtomicReference(null) // region Settings companion object { // Extras const val THREAD_ID = "thread_id" const val ADDRESS = "address" + const val SCROLL_MESSAGE_ID = "scroll_message_id" + const val SCROLL_MESSAGE_AUTHOR = "scroll_message_author" // Request codes const val PICK_DOCUMENT = 2 const val TAKE_PHOTO = 7 @@ -272,6 +278,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe super.onCreate(savedInstanceState, isReady) binding = ActivityConversationV2Binding.inflate(layoutInflater) 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) if (thread == null) { 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?) { 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) { @@ -1296,7 +1312,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } override fun resendMessage(messages: Set) { - messages.forEach { messageRecord -> + messages.iterator().forEach { messageRecord -> ResendMessageUtilities.resend(messageRecord) } endActionMode() diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/LinkPreviewView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/LinkPreviewView.kt index 0f8431c64..fbf40fe52 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/LinkPreviewView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/LinkPreviewView.kt @@ -90,7 +90,7 @@ class LinkPreviewView : LinearLayout { } // intersectedModalSpans should only be a list of one item val hitSpans = bodyTextView.getIntersectedModalSpans(hitRect) - hitSpans.forEach { span -> + hitSpans.iterator().forEach { span -> span.onClick(bodyTextView) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt index 7b1b06be2..63cfbbda2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt @@ -214,7 +214,7 @@ class VisibleMessageContentView : LinearLayout { val body = getBodySpans(context, message, searchQuery) binding.bodyTextView.text = body onContentClick.add { e: MotionEvent -> - binding.bodyTextView.getIntersectedModalSpans(e).forEach { span -> + binding.bodyTextView.getIntersectedModalSpans(e).iterator().forEach { span -> span.onClick(binding.bodyTextView) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt index 38723d239..615c5ff09 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt @@ -390,7 +390,7 @@ class VisibleMessageView : LinearLayout { } 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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchViewModel.kt index 2a7dd099b..48bb731c6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchViewModel.kt @@ -11,6 +11,7 @@ import org.session.libsession.utilities.concurrent.SignalExecutors import org.thoughtcrime.securesms.contacts.ContactAccessor import org.thoughtcrime.securesms.database.CursorList import org.thoughtcrime.securesms.database.SearchDatabase +import org.thoughtcrime.securesms.database.SessionContactDatabase import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.search.SearchRepository import org.thoughtcrime.securesms.search.model.MessageResult @@ -20,14 +21,11 @@ import javax.inject.Inject @HiltViewModel class SearchViewModel @Inject constructor( - @ApplicationContext context: Context, - searchDb: SearchDatabase, - threadDb: ThreadDatabase + private val searchRepository: SearchRepository ) : ViewModel() { - private val searchRepository: SearchRepository - private val result: CloseableLiveData - private val debouncer: Debouncer + private val result: CloseableLiveData = CloseableLiveData() + private val debouncer: Debouncer = Debouncer(500) private var firstSearch = false private var searchOpen = false 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) - } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java index a9042ed39..aa80bdb17 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java @@ -29,6 +29,7 @@ import org.session.libsignal.database.LokiOpenGroupDatabaseProtocol; import java.io.Closeable; import java.security.SecureRandom; +import java.util.ArrayList; import java.util.Collections; import java.util.LinkedList; import java.util.List; @@ -111,7 +112,7 @@ public class GroupDatabase extends Database implements LokiOpenGroupDatabaseProt } } - Optional getGroup(Cursor cursor) { + public Optional getGroup(Cursor cursor) { Reader reader = new Reader(cursor); return Optional.fromNullable(reader.getCurrent()); } @@ -146,6 +147,29 @@ public class GroupDatabase extends Database implements LokiOpenGroupDatabaseProt return groups; } + public Cursor getGroupsFilteredByMembers(List 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 getGroupMembers(String groupId, boolean includeSelf) { List
members = getCurrentMembers(groupId, false); List recipients = new LinkedList<>(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt index ab0e0ba0f..7f32fab1d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt @@ -450,7 +450,7 @@ private inline fun wrap(x: T): Array { private fun wrap(x: Map): ContentValues { val result = ContentValues(x.size) - x.forEach { result.put(it.key, it.value) } + x.iterator().forEach { result.put(it.key, it.value) } return result } // endregion \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java index 54d07afe0..f9d524010 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java @@ -139,7 +139,7 @@ public class MmsSmsDatabase extends Database { try (Cursor cursor = queryTables(PROJECTION, selection, order, "1")) { 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 { return cursor != null ? cursor.getCount() : 0; } finally { - if (cursor != null) cursor.close();; + if (cursor != null) cursor.close(); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SearchDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SearchDatabase.java index 798f34e00..37efc9a43 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SearchDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SearchDatabase.java @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.database; import android.content.Context; + import androidx.annotation.NonNull; import com.annimon.stream.Stream; @@ -8,8 +9,8 @@ import com.annimon.stream.Stream; import net.sqlcipher.Cursor; import net.sqlcipher.database.SQLiteDatabase; -import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.session.libsession.utilities.Util; +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; 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 + " " + "WHERE " + MMS_FTS_TABLE_NAME + " MATCH ? " + "ORDER BY " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " DESC " + - "LIMIT 500"; + "LIMIT ?"; private static final String MESSAGES_FOR_THREAD_QUERY = "SELECT " + @@ -115,7 +116,9 @@ public class SearchDatabase extends Database { SQLiteDatabase db = databaseHelper.getReadableDatabase(); 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); return cursor; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt index 9bcf94ec1..ef9f0cc38 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.database import android.content.ContentValues import android.content.Context +import androidx.core.database.getStringOrNull import net.sqlcipher.Cursor import org.session.libsession.messaging.contacts.Contact import org.session.libsignal.utilities.Base64 @@ -73,7 +74,7 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da notifyConversationListListeners() } - private fun contactFromCursor(cursor: Cursor): Contact { + fun contactFromCursor(cursor: Cursor): Contact { val sessionID = cursor.getString(sessionID) val contact = Contact(sessionID) contact.name = cursor.getStringOrNull(name) @@ -87,4 +88,29 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da contact.isTrusted = cursor.getInt(isTrusted) != 0 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 + ) + } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java index 058345dcf..84c7de34e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -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
filter) { if (filter == null || filter.size() == 0) return null; @@ -706,14 +719,14 @@ public class ThreadDatabase extends Database { long count = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.MESSAGE_COUNT)); int unreadCount = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.UNREAD_COUNT)); 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 deliveryReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.DELIVERY_RECEIPT_COUNT)); int readReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.READ_RECEIPT_COUNT)); long expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.EXPIRES_IN)); long lastSeen = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.LAST_SEEN)); 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)) { readReceiptCount = 0; diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/JoinPublicChatActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/JoinPublicChatActivity.kt index 92802632b..ba87e8706 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/JoinPublicChatActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/JoinPublicChatActivity.kt @@ -200,7 +200,7 @@ class EnterChatURLFragment : Fragment() { private fun populateDefaultGroups(groups: List) { binding.defaultRoomsGridLayout.removeAllViews() 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 drawable = defaultGroup.image?.let { bytes -> val bitmap = BitmapFactory.decodeByteArray(bytes,0, bytes.size) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt index 688219916..035ceb971 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -7,10 +7,9 @@ import android.content.Intent import android.content.IntentFilter import android.database.Cursor import android.os.Bundle -import android.text.Spannable import android.text.SpannableString -import android.text.style.ForegroundColorSpan import android.widget.Toast +import androidx.activity.viewModels import androidx.core.os.bundleOf import androidx.core.view.isVisible import androidx.lifecycle.Observer @@ -20,20 +19,24 @@ import androidx.loader.content.Loader import androidx.localbroadcastmanager.content.LocalBroadcastManager import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R import network.loki.messenger.databinding.ActivityHomeBinding -import network.loki.messenger.databinding.SeedReminderStubBinding import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode import org.session.libsession.messaging.jobs.JobQueue 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.ProfilePictureModifiedEvent 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.toHexString 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.crypto.IdentityKeyUtil import org.thoughtcrime.securesms.database.GroupDatabase +import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.RecipientDatabase import org.thoughtcrime.securesms.database.ThreadDatabase 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.JoinPublicChatActivity 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.GlideRequests -import org.thoughtcrime.securesms.notifications.MarkReadReceiver import org.thoughtcrime.securesms.onboarding.SeedActivity import org.thoughtcrime.securesms.onboarding.SeedReminderViewDelegate import org.thoughtcrime.securesms.preferences.SettingsActivity import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.IP2Country +import org.thoughtcrime.securesms.util.UiModeUtilities import org.thoughtcrime.securesms.util.disableClipping -import org.thoughtcrime.securesms.util.getColorWithID import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.util.show import java.io.IOException import javax.inject.Inject @AndroidEntryPoint -class HomeActivity : PassphraseRequiredActionBarActivity(), ConversationClickListener, - SeedReminderViewDelegate, NewConversationButtonSetViewDelegate, LoaderManager.LoaderCallbacks { +class HomeActivity : PassphraseRequiredActionBarActivity(), + ConversationClickListener, + SeedReminderViewDelegate, + NewConversationButtonSetViewDelegate, + LoaderManager.LoaderCallbacks, + GlobalSearchInputLayout.GlobalSearchInputLayoutListener { private lateinit var binding: ActivityHomeBinding private lateinit var glide: GlideRequests private var broadcastReceiver: BroadcastReceiver? = null @Inject lateinit var threadDb: ThreadDatabase + @Inject lateinit var mmsSmsDatabase: MmsSmsDatabase @Inject lateinit var recipientDatabase: RecipientDatabase @Inject lateinit var groupDatabase: GroupDatabase + @Inject lateinit var textSecurePreferences: TextSecurePreferences + + private val globalSearchViewModel by viewModels() private val publicKey: String get() = TextSecurePreferences.getLocalNumber(this)!! @@ -85,6 +99,46 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), ConversationClickLis 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 override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { super.onCreate(savedInstanceState, isReady) @@ -98,28 +152,28 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), ConversationClickLis // Set up toolbar buttons binding.profileButton.glide = glide binding.profileButton.setOnClickListener { openSettings() } - binding.pathStatusViewContainer.disableClipping() - binding.pathStatusViewContainer.setOnClickListener { showPath() } + binding.searchViewContainer.setOnClickListener { + binding.globalSearchInputLayout.requestFocus() + } + binding.sessionToolbar.disableClipping() // Set up seed reminder view val hasViewedSeed = TextSecurePreferences.getHasViewedSeed(this) if (!hasViewedSeed) { - binding.seedReminderStub.setOnInflateListener { _, inflated -> - val stubBinding = SeedReminderStubBinding.bind(inflated) - val seedReminderViewTitle = SpannableString("You're almost finished! 80%") // Intentionally not yet translated - seedReminderViewTitle.setSpan(ForegroundColorSpan(resources.getColorWithID(R.color.accent, theme)), 24, 27, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - stubBinding.seedReminderView.title = seedReminderViewTitle - 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() + binding.seedReminderView.isVisible = true + binding.seedReminderView.title = SpannableString("You're almost finished! 80%") // Intentionally not yet translated + binding.seedReminderView.subtitle = resources.getString(R.string.view_seed_reminder_subtitle_1) + binding.seedReminderView.setProgress(80, false) + binding.seedReminderView.delegate = this@HomeActivity } else { - binding.seedReminderStub.isVisible = false + binding.seedReminderView.isVisible = false } + setupHeaderImage() // Set up recycler view + binding.globalSearchInputLayout.listener = this homeAdapter.setHasStableIds(true) homeAdapter.glide = glide binding.recyclerView.adapter = homeAdapter + binding.globalSearchRecycler.adapter = globalSearchAdapter // Set up empty state view binding.createNewPrivateChatButton.setOnClickListener { createNewPrivateChat() } IP2Country.configureIfNeeded(this@HomeActivity) @@ -129,7 +183,6 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), ConversationClickLis binding.newConversationButtonSet.delegate = this // Observe blocked contacts changed events val broadcastReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { binding.recyclerView.adapter!!.notifyDataSetChanged() } @@ -161,10 +214,85 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), ConversationClickLis 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 = 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) } + 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 { return HomeLoader(this@HomeActivity) } @@ -187,7 +315,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), ConversationClickLis binding.profileButton.update() val hasViewedSeed = TextSecurePreferences.getHasViewedSeed(this) if (hasViewedSeed) { - binding.seedReminderStub.isVisible = false + binding.seedReminderView.isVisible = false } if (TextSecurePreferences.getConfigurationMessageSynced(this)) { lifecycleScope.launch(Dispatchers.IO) { @@ -221,7 +349,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), ConversationClickLis // region Updating private fun updateEmptyState() { 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) @@ -240,6 +368,14 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), ConversationClickLis // endregion // region Interaction + override fun onBackPressed() { + if (binding.globalSearchRecycler.isVisible) { + binding.globalSearchInputLayout.clearSearch(true) + return + } + super.onBackPressed() + } + override fun handleSeedReminderViewContinueButtonTapped() { val intent = Intent(this, SeedActivity::class.java) show(intent) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt new file mode 100644 index 000000000..554fb2e11 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt @@ -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() { + + companion object { + const val HEADER_VIEW_TYPE = 0 + const val CONTENT_VIEW_TYPE = 1 + } + + private var data: List = listOf() + private var query: String? = null + + fun setNewData(query: String, newData: List) { + 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 + ) { + 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() + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt new file mode 100644 index 000000000..2181b7f83 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt @@ -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, + private val newData: List +) : 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)" \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchInputLayout.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchInputLayout.kt new file mode 100644 index 000000000..411ae0956 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchInputLayout.kt @@ -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(null) + val query: StateFlow = _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) + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchResult.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchResult.kt new file mode 100644 index 000000000..c85ffa874 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchResult.kt @@ -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, + val threads: List, + val messages: List +) { + + 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) + } + + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchViewModel.kt new file mode 100644 index 000000000..8908554b0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchViewModel.kt @@ -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 = + MutableStateFlow(GlobalSearchResult.EMPTY) + + val result: StateFlow = _result + + private val _queryText: MutableStateFlow = 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() + 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) + } + + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java index 0fe41d2a0..9e3db73bf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java @@ -105,7 +105,7 @@ public class MediaPickerItemFragment extends Fragment implements MediaPickerItem onMediaSelectionChanged(new ArrayList<>(viewModel.getSelectedMedia().getValue())); } - viewModel.getMediaInBucket(requireContext(), bucketId).observe(this, adapter::setMedia); + viewModel.getMediaInBucket(requireContext(), bucketId).observe(getViewLifecycleOwner(), adapter::setMedia); initMediaObserver(viewModel); } @@ -178,7 +178,7 @@ public class MediaPickerItemFragment extends Fragment implements MediaPickerItem } private void initMediaObserver(@NonNull MediaSendViewModel viewModel) { - viewModel.getCountButtonState().observe(this, media -> { + viewModel.getCountButtonState().observe(getViewLifecycleOwner(), media -> { requireActivity().invalidateOptionsMenu(); }); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt index 22a3e037b..cac1bfb9a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt @@ -60,7 +60,7 @@ class BackgroundPollWorker(val context: Context, params: WorkerParameters) : Wor val closedGroupPoller = ClosedGroupPollerV2() // Intentionally don't use shared val storage = MessagingModuleConfiguration.shared.storage val allGroupPublicKeys = storage.getAllClosedGroupPublicKeys() - allGroupPublicKeys.forEach { closedGroupPoller.poll(it) } + allGroupPublicKeys.iterator().forEach { closedGroupPoller.poll(it) } // Open Groups val threadDB = DatabaseComponent.get(context).lokiThreadDatabase() diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/LokiPushNotificationManager.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/LokiPushNotificationManager.kt index ac6039063..48a649dee 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/LokiPushNotificationManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/LokiPushNotificationManager.kt @@ -57,7 +57,7 @@ object LokiPushNotificationManager { // Unsubscribe from all closed groups val allClosedGroupPublicKeys = DatabaseComponent.get(context).lokiAPIDatabase().getAllClosedGroupPublicKeys() val userPublicKey = TextSecurePreferences.getLocalNumber(context)!! - allClosedGroupPublicKeys.forEach { closedGroup -> + allClosedGroupPublicKeys.iterator().forEach { closedGroup -> performOperation(context, ClosedGroupOperation.Unsubscribe, closedGroup, userPublicKey) } } @@ -87,7 +87,7 @@ object LokiPushNotificationManager { } // Subscribe to all closed groups val allClosedGroupPublicKeys = DatabaseComponent.get(context).lokiAPIDatabase().getAllClosedGroupPublicKeys() - allClosedGroupPublicKeys.forEach { closedGroup -> + allClosedGroupPublicKeys.iterator().forEach { closedGroup -> performOperation(context, ClosedGroupOperation.Subscribe, closedGroup, publicKey) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/CorrectedPreferenceFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/CorrectedPreferenceFragment.java index 6b097a0d5..7bf967237 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/CorrectedPreferenceFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/CorrectedPreferenceFragment.java @@ -54,9 +54,9 @@ public abstract class CorrectedPreferenceFragment extends PreferenceFragmentComp } @Override + @SuppressLint("RestrictedApi") protected RecyclerView.Adapter onCreateAdapter(PreferenceScreen preferenceScreen) { return new PreferenceGroupAdapter(preferenceScreen) { - @SuppressLint("RestrictedApi") @Override public void onBindViewHolder(PreferenceViewHolder holder, int position) { super.onBindViewHolder(holder, position); diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt index fb9d13f0f..d5c7747e0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt @@ -34,6 +34,7 @@ import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol import org.session.libsession.utilities.TextSecurePreferences import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.avatar.AvatarSelection +import org.thoughtcrime.securesms.home.PathActivity import org.thoughtcrime.securesms.mms.GlideApp import org.thoughtcrime.securesms.mms.GlideRequests 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.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.UiModeUtilities +import org.thoughtcrime.securesms.util.disableClipping import org.thoughtcrime.securesms.util.push +import org.thoughtcrime.securesms.util.show import java.io.File import java.security.SecureRandom import java.util.Date @@ -84,6 +87,8 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { publicKeyTextView.text = hexEncodedPublicKey copyButton.setOnClickListener { copyPublicKey() } shareButton.setOnClickListener { sharePublicKey() } + pathButton.setOnClickListener { showPath() } + pathContainer.disableClipping() privacyButton.setOnClickListener { showPrivacySettings() } notificationsButton.setOnClickListener { showNotificationSettings() } 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() { try { val url = "https://getsession.org/survey" diff --git a/app/src/main/java/org/thoughtcrime/securesms/search/SearchModule.kt b/app/src/main/java/org/thoughtcrime/securesms/search/SearchModule.kt new file mode 100644 index 000000000..7ee247fdb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/search/SearchModule.kt @@ -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) + + +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java b/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java index df4cb1d17..a33f4dd11 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java @@ -3,29 +3,39 @@ package org.thoughtcrime.securesms.search; import android.content.Context; import android.database.Cursor; import android.database.DatabaseUtils; -import androidx.annotation.NonNull; +import android.database.MergeCursor; import android.text.TextUtils; +import androidx.annotation.NonNull; + 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.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.GroupDatabase; import org.thoughtcrime.securesms.database.MmsSmsColumns; import org.thoughtcrime.securesms.database.SearchDatabase; +import org.thoughtcrime.securesms.database.SessionContactDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase; 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.SearchResult; import org.thoughtcrime.securesms.util.Stopwatch; +import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.concurrent.Executor; +import kotlin.Pair; + /** * Manages data retrieval for search. */ @@ -50,21 +60,27 @@ public class SearchRepository { } } - private final Context context; - private final SearchDatabase searchDatabase; - private final ThreadDatabase threadDatabase; - private final ContactAccessor contactAccessor; - private final Executor executor; + private final Context context; + private final SearchDatabase searchDatabase; + private final ThreadDatabase threadDatabase; + private final GroupDatabase groupDatabase; + private final SessionContactDatabase contactDatabase; + private final ContactAccessor contactAccessor; + private final Executor executor; public SearchRepository(@NonNull Context context, @NonNull SearchDatabase searchDatabase, @NonNull ThreadDatabase threadDatabase, + @NonNull GroupDatabase groupDatabase, + @NonNull SessionContactDatabase contactDatabase, @NonNull ContactAccessor contactAccessor, @NonNull Executor executor) { this.context = context.getApplicationContext(); this.searchDatabase = searchDatabase; this.threadDatabase = threadDatabase; + this.groupDatabase = groupDatabase; + this.contactDatabase = contactDatabase; this.contactAccessor = contactAccessor; this.executor = executor; } @@ -81,10 +97,10 @@ public class SearchRepository { String cleanQuery = sanitizeQuery(query); timer.split("clean"); - CursorList contacts = queryContacts(cleanQuery); + Pair, List> contacts = queryContacts(cleanQuery); timer.split("contacts"); - CursorList conversations = queryConversations(cleanQuery); + CursorList conversations = queryConversations(cleanQuery, contacts.getSecond()); timer.split("conversations"); CursorList messages = queryMessages(cleanQuery); @@ -92,7 +108,7 @@ public class SearchRepository { timer.stop(TAG); - callback.onResult(new SearchResult(cleanQuery, contacts, conversations, messages)); + callback.onResult(new SearchResult(cleanQuery, contacts.getFirst(), conversations, messages)); }); } @@ -111,28 +127,62 @@ public class SearchRepository { }); } - private CursorList queryContacts(String query) { - return CursorList.emptyList(); - /* Loki - We don't need contacts permission - if (!Permissions.hasAny(context, Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)) { - return CursorList.emptyList(); + private Pair, List> queryContacts(String query) { + + Cursor contacts = contactDatabase.queryContactsByName(query); + List
contactList = new ArrayList<>(); + List 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); - Cursor systemContacts = contactsDatabase.querySystemContacts(query); - MergeCursor contacts = new MergeCursor(new Cursor[]{ textSecureContacts, systemContacts }); + contacts.close(); + + 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 queryConversations(@NonNull String query) { + private CursorList queryConversations(@NonNull String query, List matchingAddresses) { List numbers = contactAccessor.getNumbersForThreadSearchFilter(context, query); - List
addresses = Stream.of(numbers).map(number -> Address.fromExternal(context, number)).toList(); + String localUserNumber = TextSecurePreferences.getLocalNumber(context); + if (localUserNumber != null) { + matchingAddresses.remove(localUserNumber); + } + Set
addresses = new HashSet<>(Stream.of(numbers).map(number -> Address.fromExternal(context, number)).toList()); - Cursor conversations = threadDatabase.getFilteredConversationList(addresses); - return conversations != null ? new CursorList<>(conversations, new ThreadModelBuilder(threadDatabase)) - : CursorList.emptyList(); + Cursor membersGroupList = groupDatabase.getGroupsFilteredByMembers(matchingAddresses); + 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(); } private CursorList queryMessages(@NonNull String query) { @@ -169,6 +219,28 @@ public class SearchRepository { return out.toString(); } + private static class ContactModelBuilder implements CursorList.ModelBuilder { + + 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 { private final Context context; @@ -184,6 +256,22 @@ public class SearchRepository { } } + private static class GroupModelBuilder implements CursorList.ModelBuilder { + 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 { private final ThreadDatabase threadDatabase; @@ -208,7 +296,7 @@ public class SearchRepository { @Override 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))); Recipient conversationRecipient = Recipient.from(context, conversationAddress, false); Recipient messageRecipient = Recipient.from(context, messageAddress, false); diff --git a/app/src/main/java/org/thoughtcrime/securesms/search/model/SearchResult.java b/app/src/main/java/org/thoughtcrime/securesms/search/model/SearchResult.java index c1e40beb4..33c687c93 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/search/model/SearchResult.java +++ b/app/src/main/java/org/thoughtcrime/securesms/search/model/SearchResult.java @@ -4,9 +4,10 @@ import android.database.ContentObserver; 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.model.ThreadRecord; -import org.session.libsession.utilities.recipients.Recipient; import java.util.List; @@ -19,13 +20,13 @@ public class SearchResult { public static final SearchResult EMPTY = new SearchResult("", CursorList.emptyList(), CursorList.emptyList(), CursorList.emptyList()); private final String query; - private final CursorList contacts; - private final CursorList conversations; + private final CursorList contacts; + private final CursorList conversations; private final CursorList messages; public SearchResult(@NonNull String query, - @NonNull CursorList contacts, - @NonNull CursorList conversations, + @NonNull CursorList contacts, + @NonNull CursorList conversations, @NonNull CursorList messages) { this.query = query; @@ -34,11 +35,11 @@ public class SearchResult { this.messages = messages; } - public List getContacts() { + public List getContacts() { return contacts; } - public List getConversations() { + public List getConversations() { return conversations; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/BackupUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/util/BackupUtil.kt index 3f4867839..eaaf06f45 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/BackupUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/BackupUtil.kt @@ -229,7 +229,7 @@ object BackupUtil { @JvmOverloads fun deleteAllBackupFiles(context: Context, except: Collection? = null) { val db = DatabaseComponent.get(context).lokiBackupFilesDatabase() - db.getBackupFiles().forEach { record -> + db.getBackupFiles().iterator().forEach { record -> if (except != null && except.contains(record)) return@forEach // Try to delete the related file. The operation may fail in many cases diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java b/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java index 7860e4624..874440f5d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java @@ -121,14 +121,12 @@ public class DateUtils extends android.text.format.DateUtils { * e.g. 2020-09-04T19:17:51Z * 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. */ @SuppressLint("ObsoleteSdkInt") public static long parseIso8601(@Nullable String date) { 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()); } else { format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/IP2Country.kt b/app/src/main/java/org/thoughtcrime/securesms/util/IP2Country.kt index 4ff45e8f0..479a54faf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/IP2Country.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/IP2Country.kt @@ -116,8 +116,8 @@ class IP2Country private constructor(private val context: Context) { private fun populateCacheIfNeeded() { ThreadUtils.queue { - OnionRequestAPI.paths.forEach { path -> - path.forEach { snode -> + OnionRequestAPI.paths.iterator().forEach { path -> + path.iterator().forEach { snode -> cacheCountryForIP(snode.ip) // Preload if needed } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SearchUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/SearchUtil.java index 8935780b1..cf046b9a7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SearchUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SearchUtil.java @@ -1,12 +1,13 @@ package org.thoughtcrime.securesms.util; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import android.text.Spannable; import android.text.SpannableString; import android.text.TextUtils; import android.text.style.CharacterStyle; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + import com.annimon.stream.Stream; import org.session.libsignal.utilities.Pair; diff --git a/app/src/main/res/drawable/ic_outline_bookmark_border_24.xml b/app/src/main/res/drawable/ic_outline_bookmark_border_24.xml new file mode 100644 index 000000000..0cb95b770 --- /dev/null +++ b/app/src/main/res/drawable/ic_outline_bookmark_border_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_session.xml b/app/src/main/res/drawable/ic_session.xml new file mode 100644 index 000000000..040347af1 --- /dev/null +++ b/app/src/main/res/drawable/ic_session.xml @@ -0,0 +1,27 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/search_background.xml b/app/src/main/res/drawable/search_background.xml new file mode 100644 index 000000000..a2090ca6b --- /dev/null +++ b/app/src/main/res/drawable/search_background.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/font/roboto_medium.ttf b/app/src/main/res/font/roboto_medium.ttf new file mode 100644 index 000000000..e89b0b79a Binary files /dev/null and b/app/src/main/res/font/roboto_medium.ttf differ diff --git a/app/src/main/res/layout/activity_home.xml b/app/src/main/res/layout/activity_home.xml index ad1e42d30..893cf3835 100644 --- a/app/src/main/res/layout/activity_home.xml +++ b/app/src/main/res/layout/activity_home.xml @@ -21,6 +21,7 @@ android:orientation="vertical"> - + + + android:layout_centerInParent="true" + android:layout_toStartOf="@+id/searchViewContainer" + android:layout_toEndOf="@+id/profileButton" + android:padding="@dimen/medium_spacing" + android:scaleType="centerInside" + android:src="@drawable/ic_session" + app:tint="@color/black" /> - + + + + + - @@ -100,6 +121,16 @@ android:layout_height="match_parent" android:background="@drawable/home_activity_gradient" /> + + + + + + + + + + + + android:layout_height="match_parent" + android:animateLayoutChanges="true"> diff --git a/app/src/main/res/layout/alert_view.xml b/app/src/main/res/layout/alert_view.xml index fe2ff895c..a3366c40c 100644 --- a/app/src/main/res/layout/alert_view.xml +++ b/app/src/main/res/layout/alert_view.xml @@ -1,6 +1,7 @@ + xmlns:tools="http://schemas.android.com/tools" + xmlns:app="http://schemas.android.com/apk/res-auto"> @@ -17,7 +18,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:src="@drawable/ic_error" - android:tint="@color/core_grey_60" + app:tint="@color/core_grey_60" android:visibility="gone" tools:visibility="visible" android:layout_gravity="center_vertical" diff --git a/app/src/main/res/layout/camera_fragment.xml b/app/src/main/res/layout/camera_fragment.xml index ccddcb26e..95cd0b379 100644 --- a/app/src/main/res/layout/camera_fragment.xml +++ b/app/src/main/res/layout/camera_fragment.xml @@ -1,6 +1,6 @@ - + app:tint="@android:color/white"/> diff --git a/app/src/main/res/layout/delivery_status_view.xml b/app/src/main/res/layout/delivery_status_view.xml index f38532a10..863055ce4 100644 --- a/app/src/main/res/layout/delivery_status_view.xml +++ b/app/src/main/res/layout/delivery_status_view.xml @@ -1,6 +1,7 @@ + xmlns:tools="http://schemas.android.com/tools" + xmlns:app="http://schemas.android.com/apk/res-auto"> \ No newline at end of file diff --git a/app/src/main/res/layout/emoji_display_item.xml b/app/src/main/res/layout/emoji_display_item.xml index 92c766ac6..cf8da830d 100644 --- a/app/src/main/res/layout/emoji_display_item.xml +++ b/app/src/main/res/layout/emoji_display_item.xml @@ -33,6 +33,6 @@ android:layout_height="7dp" android:layout_gravity="bottom|right|end" app:srcCompat="@drawable/triangle_bottom_right_corner" - android:tint="@color/core_grey_25"/> + app:tint="@color/core_grey_25"/> \ No newline at end of file diff --git a/app/src/main/res/layout/media_keyboard.xml b/app/src/main/res/layout/media_keyboard.xml index 76a628107..20ef09afe 100644 --- a/app/src/main/res/layout/media_keyboard.xml +++ b/app/src/main/res/layout/media_keyboard.xml @@ -18,7 +18,7 @@ android:layout_marginStart="6dp" android:padding="6dp" android:src="@drawable/ic_baseline_search_24" - android:tint="?media_keyboard_button_color" + app:tint="?media_keyboard_button_color" android:background="?selectableItemBackgroundBorderless" android:visibility="invisible" app:layout_constraintStart_toStartOf="parent" @@ -95,7 +95,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="12dp" android:scaleType="fitCenter" - android:tint="?media_keyboard_button_color" + app:tint="?media_keyboard_button_color" android:visibility="gone" android:background="?selectableItemBackground" app:srcCompat="@drawable/ic_baseline_add_24" diff --git a/app/src/main/res/layout/media_view_remove_button.xml b/app/src/main/res/layout/media_view_remove_button.xml index ffe87c4f8..735042693 100644 --- a/app/src/main/res/layout/media_view_remove_button.xml +++ b/app/src/main/res/layout/media_view_remove_button.xml @@ -1,10 +1,11 @@ + xmlns:app="http://schemas.android.com/apk/res-auto" + android:id="@+id/remove_image_button" + android:layout_width="@dimen/media_bubble_remove_button_size" + android:layout_height="@dimen/media_bubble_remove_button_size" + android:layout_gravity="top|end" + android:background="@drawable/circle_alpha" + android:src="@drawable/ic_close_white_18dp" + app:tint="#99FFFFFF" + android:visibility="gone" /> diff --git a/app/src/main/res/layout/mediapicker_folder_item.xml b/app/src/main/res/layout/mediapicker_folder_item.xml index b52ace6f4..a7b1547a7 100644 --- a/app/src/main/res/layout/mediapicker_folder_item.xml +++ b/app/src/main/res/layout/mediapicker_folder_item.xml @@ -1,9 +1,9 @@ - @@ -33,7 +33,7 @@ android:layout_width="20dp" android:layout_height="20dp" android:layout_marginEnd="6dp" - android:tint="@android:color/white" + app:tint="@android:color/white" android:src="@drawable/ic_baseline_folder_24"/> diff --git a/app/src/main/res/layout/mediasend_activity.xml b/app/src/main/res/layout/mediasend_activity.xml index bdfc0446c..fdf143313 100644 --- a/app/src/main/res/layout/mediasend_activity.xml +++ b/app/src/main/res/layout/mediasend_activity.xml @@ -1,7 +1,7 @@ - @@ -47,7 +47,7 @@ android:layout_height="20dp" android:layout_marginStart="2dp" android:src="@drawable/ic_arrow_right" - android:tint="@color/core_white"/> + app:tint="@color/core_white"/> @@ -60,7 +60,7 @@ android:layout_gravity="bottom|start" android:padding="12dp" 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:elevation="4dp" android:visibility="gone" diff --git a/app/src/main/res/layout/mediasend_fragment.xml b/app/src/main/res/layout/mediasend_fragment.xml index 2093dec5a..87dd1c122 100644 --- a/app/src/main/res/layout/mediasend_fragment.xml +++ b/app/src/main/res/layout/mediasend_fragment.xml @@ -165,7 +165,7 @@ android:layout_width="36dp" android:layout_height="36dp" android:src="@drawable/ic_baseline_clear_24" - android:tint="@android:color/white"/> + app:tint="@android:color/white"/> diff --git a/app/src/main/res/layout/quote_view.xml b/app/src/main/res/layout/quote_view.xml index 50b270b50..94ee633ec 100644 --- a/app/src/main/res/layout/quote_view.xml +++ b/app/src/main/res/layout/quote_view.xml @@ -133,7 +133,7 @@ android:layout_height="16dp" android:layout_marginStart="11dp" android:layout_marginTop="8dp" - android:tint="@color/core_blue" + app:tint="@color/core_blue" android:scaleType="fitXY" app:srcCompat="@drawable/triangle_right" /> @@ -157,7 +157,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="8dp" android:src="@drawable/ic_broken_link" - android:tint="@color/text"/> + app:tint="@color/text"/> - diff --git a/app/src/main/res/layout/thumbnail_view.xml b/app/src/main/res/layout/thumbnail_view.xml index eaa98cce6..eca3485df 100644 --- a/app/src/main/res/layout/thumbnail_view.xml +++ b/app/src/main/res/layout/thumbnail_view.xml @@ -45,7 +45,7 @@ android:layout_height="24dp" android:layout_marginStart="17dp" android:layout_marginTop="12dp" - android:tint="@color/core_blue" + app:tint="@color/core_blue" android:scaleType="fitXY" app:srcCompat="@drawable/triangle_right" /> diff --git a/app/src/main/res/layout/transfer_controls_view.xml b/app/src/main/res/layout/transfer_controls_view.xml index 7a54d547b..b76c2bd30 100644 --- a/app/src/main/res/layout/transfer_controls_view.xml +++ b/app/src/main/res/layout/transfer_controls_view.xml @@ -35,7 +35,7 @@ + android:textSize="@dimen/very_small_font_size" + android:textStyle="bold" /> diff --git a/app/src/main/res/layout/view_global_search_header.xml b/app/src/main/res/layout/view_global_search_header.xml new file mode 100644 index 000000000..c04f289c3 --- /dev/null +++ b/app/src/main/res/layout/view_global_search_header.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_global_search_input.xml b/app/src/main/res/layout/view_global_search_input.xml new file mode 100644 index 000000000..058fa6119 --- /dev/null +++ b/app/src/main/res/layout/view_global_search_input.xml @@ -0,0 +1,58 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_global_search_result.xml b/app/src/main/res/layout/view_global_search_result.xml new file mode 100644 index 000000000..b784ca559 --- /dev/null +++ b/app/src/main/res/layout/view_global_search_result.xml @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_home.xml b/app/src/main/res/menu/menu_home.xml new file mode 100644 index 000000000..6b204492b --- /dev/null +++ b/app/src/main/res/menu/menu_home.xml @@ -0,0 +1,16 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index b98c41484..6aa23d4db 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -238,7 +238,7 @@ on viallinen! Katoavat viestit poistettu käytöstä Katoavien viestien ajaksi asetettu %s %s otti kuvakaappauksen. - Media tallennettu toimesta. + Media tallennettu toimesta %s. Turvanumero vaihtunut Sinun ja yhteystiedon %s turvanumero on vaihtunut. Merkitsit varmennetuksi. diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 9e48982c9..4e92ea71b 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -238,7 +238,7 @@ on viallinen! Katoavat viestit poistettu käytöstä Katoavien viestien ajaksi asetettu %s %s otti kuvakaappauksen. - Media tallennettu toimesta. + Media tallennettu toimesta %s. Turvanumero vaihtunut Sinun ja yhteystiedon %s turvanumero on vaihtunut. Merkitsit varmennetuksi. diff --git a/app/src/main/res/values-hi-rIN/strings.xml b/app/src/main/res/values-hi-rIN/strings.xml index 8e4d97b84..77e9d7a21 100644 --- a/app/src/main/res/values-hi-rIN/strings.xml +++ b/app/src/main/res/values-hi-rIN/strings.xml @@ -729,5 +729,4 @@ ये संदेश मिटा दिया है स्वयं के लिये मिटाये सभी के लिए संदेश मिटायें - स्वयमेव और प्राप्तिकर्ता के लिये मिटाये diff --git a/app/src/main/res/values-notnight-v21/themes.xml b/app/src/main/res/values-notnight-v21/themes.xml index 53262d359..abbf3f871 100644 --- a/app/src/main/res/values-notnight-v21/themes.xml +++ b/app/src/main/res/values-notnight-v21/themes.xml @@ -5,6 +5,10 @@ ?android:navigationBarColor @color/gray50 + #F2F2F2 + #413F40 + #767676 + #00FFFFFF #FFFFFFFF diff --git a/app/src/main/res/values-sq-rAL/strings.xml b/app/src/main/res/values-sq-rAL/strings.xml index d02c676b7..cbdb24eae 100644 --- a/app/src/main/res/values-sq-rAL/strings.xml +++ b/app/src/main/res/values-sq-rAL/strings.xml @@ -237,7 +237,6 @@ %s është në Session! Zhdukja e mesazheve është e çaktivizuar Koha për zhdukje mesazhesh është vënë %s - Bëri nje screenshot Media u kursye me %s Numri i sigurisë ndryshoi Numri juaj i sigurisë me %s është ndryshuar. diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index b16b9029f..7375a81ba 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -8,7 +8,7 @@ #353535 #1B1B1B #0C0C0C - #171717 + #161616 #36383C #323232 #101011 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 32f3cfa12..947bd9f76 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -905,5 +905,7 @@ Pin Unpin Mark all as read + Contacts and Groups + Messages diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 1e78c0058..2dcc5a205 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -5,6 +5,9 @@