Browse Source

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
pull/840/head
Harris 6 months ago committed by GitHub
parent
commit
dd1da6b1a4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 3
      app/build.gradle
  2. 2
      app/src/androidTest/AndroidManifest.xml
  3. 5
      app/src/androidTest/java/network/loki/messenger/HomeActivityTests.kt
  4. 8
      app/src/main/java/org/thoughtcrime/securesms/audio/AudioCodec.java
  5. 19
      app/src/main/java/org/thoughtcrime/securesms/contacts/ContactAccessor.java
  6. 18
      app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt
  7. 2
      app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/LinkPreviewView.kt
  8. 2
      app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt
  9. 2
      app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt
  10. 19
      app/src/main/java/org/thoughtcrime/securesms/conversation/v2/search/SearchViewModel.kt
  11. 26
      app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java
  12. 2
      app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt
  13. 4
      app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java
  14. 9
      app/src/main/java/org/thoughtcrime/securesms/database/SearchDatabase.java
  15. 28
      app/src/main/java/org/thoughtcrime/securesms/database/SessionContactDatabase.kt
  16. 17
      app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java
  17. 2
      app/src/main/java/org/thoughtcrime/securesms/groups/JoinPublicChatActivity.kt
  18. 182
      app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt
  19. 129
      app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapter.kt
  20. 160
      app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchAdapterUtils.kt
  21. 88
      app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchInputLayout.kt
  22. 34
      app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchResult.kt
  23. 69
      app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchViewModel.kt
  24. 4
      app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java
  25. 2
      app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt
  26. 4
      app/src/main/java/org/thoughtcrime/securesms/notifications/LokiPushNotificationManager.kt
  27. 2
      app/src/main/java/org/thoughtcrime/securesms/preferences/CorrectedPreferenceFragment.java
  28. 10
      app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt
  29. 33
      app/src/main/java/org/thoughtcrime/securesms/search/SearchModule.kt
  30. 144
      app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java
  31. 15
      app/src/main/java/org/thoughtcrime/securesms/search/model/SearchResult.java
  32. 2
      app/src/main/java/org/thoughtcrime/securesms/util/BackupUtil.kt
  33. 4
      app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java
  34. 4
      app/src/main/java/org/thoughtcrime/securesms/util/IP2Country.kt
  35. 5
      app/src/main/java/org/thoughtcrime/securesms/util/SearchUtil.java
  36. 10
      app/src/main/res/drawable/ic_outline_bookmark_border_24.xml
  37. 27
      app/src/main/res/drawable/ic_session.xml
  38. 6
      app/src/main/res/drawable/search_background.xml
  39. BIN
      app/src/main/res/font/roboto_medium.ttf
  40. 67
      app/src/main/res/layout/activity_home.xml
  41. 39
      app/src/main/res/layout/activity_settings.xml
  42. 7
      app/src/main/res/layout/alert_view.xml
  43. 6
      app/src/main/res/layout/camera_fragment.xml
  44. 5
      app/src/main/res/layout/delivery_status_view.xml
  45. 2
      app/src/main/res/layout/emoji_display_item.xml
  46. 4
      app/src/main/res/layout/media_keyboard.xml
  47. 17
      app/src/main/res/layout/media_view_remove_button.xml
  48. 6
      app/src/main/res/layout/mediapicker_folder_item.xml
  49. 2
      app/src/main/res/layout/mediapicker_media_item.xml
  50. 8
      app/src/main/res/layout/mediasend_activity.xml
  51. 2
      app/src/main/res/layout/mediasend_fragment.xml
  52. 4
      app/src/main/res/layout/quote_view.xml
  53. 6
      app/src/main/res/layout/seed_reminder_stub.xml
  54. 2
      app/src/main/res/layout/thumbnail_view.xml
  55. 2
      app/src/main/res/layout/transfer_controls_view.xml
  56. 6
      app/src/main/res/layout/view_conversation.xml
  57. 16
      app/src/main/res/layout/view_global_search_header.xml
  58. 58
      app/src/main/res/layout/view_global_search_input.xml
  59. 111
      app/src/main/res/layout/view_global_search_result.xml
  60. 16
      app/src/main/res/menu/menu_home.xml
  61. 2
      app/src/main/res/values-fi-rFI/strings.xml
  62. 2
      app/src/main/res/values-fi/strings.xml
  63. 1
      app/src/main/res/values-hi-rIN/strings.xml
  64. 4
      app/src/main/res/values-notnight-v21/themes.xml
  65. 1
      app/src/main/res/values-sq-rAL/strings.xml
  66. 2
      app/src/main/res/values/colors.xml
  67. 2
      app/src/main/res/values/strings.xml
  68. 3
      app/src/main/res/values/themes.xml
  69. 3
      app/src/main/res/values/values.xml
  70. 78
      app/src/test/java/org/thoughtcrime/securesms/BaseUnitTest.java
  71. 2
      app/src/test/java/org/thoughtcrime/securesms/database/CursorRecyclerViewAdapterTest.java
  72. 48
      app/src/test/java/org/thoughtcrime/securesms/jobs/FastJobStorageTest.java
  73. 3
      app/src/test/java/org/thoughtcrime/securesms/util/ListPartitionTest.java
  74. 3
      app/src/test/java/org/thoughtcrime/securesms/util/Rfc5724UriTest.java
  75. 2
      gradle.properties
  76. 13
      libsession/build.gradle
  77. 2
      libsession/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt
  78. 2
      libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt
  79. 2
      libsession/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt
  80. 4
      libsession/src/main/java/org/session/libsession/messaging/sending_receiving/notifications/PushNotificationAPI.kt
  81. 6
      libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/ClosedGroupPollerV2.kt
  82. 3
      libsession/src/main/java/org/session/libsession/utilities/Util.kt
  83. 53
      libsession/src/main/res/values/arrays.xml
  84. 17
      libsignal/src/test/java/org/session/libsignal/ExampleUnitTest.kt

3
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

2
app/src/androidTest/AndroidManifest.xml

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

5
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

8
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);

19
app/src/main/java/org/thoughtcrime/securesms/contacts/ContactAccessor.java

@ -66,25 +66,18 @@ public class ContactAccessor {
public List<String> getNumbersForThreadSearchFilter(Context context, String constraint) {
LinkedList<String> 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;
}

18
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<Address?>(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: 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>) {
@ -1296,7 +1312,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
}
override fun resendMessage(messages: Set<MessageRecord>) {
messages.forEach { messageRecord ->
messages.iterator().forEach { messageRecord ->
ResendMessageUtilities.resend(messageRecord)
}
endActionMode()

2
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)
}
}

2
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)
}
}

2
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) {

19
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<SearchResult>
private val debouncer: Debouncer
private val result: CloseableLiveData<SearchResult> = 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)
}
}

26
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<GroupRecord> getGroup(Cursor cursor) {
public Optional<GroupRecord> 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<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) {
List<Address> members = getCurrentMembers(groupId, false);
List<Recipient> recipients = new LinkedList<>();

2
app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt

@ -450,7 +450,7 @@ private inline fun <reified T> wrap(x: T): Array<T> {
private fun wrap(x: Map<String, String>): 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

4
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();
}
}

9
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;
}

28
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
)
}
}

17
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<Address> 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;

2
app/src/main/java/org/thoughtcrime/securesms/groups/JoinPublicChatActivity.kt

@ -200,7 +200,7 @@ class EnterChatURLFragment : Fragment() {
private fun populateDefaultGroups(groups: List<DefaultGroup>) {
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)

182
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<Cursor> {
class HomeActivity : PassphraseRequiredActionBarActivity(),
ConversationClickListener,
SeedReminderViewDelegate,
NewConversationButtonSetViewDelegate,
LoaderManager.LoaderCallbacks<Cursor>,
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<GlobalSearchViewModel>()
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<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)
}
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> {
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)

129
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<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()
}
}

160
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<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)"

88
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<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)
}
}

34
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<Contact>,
val threads: List<GroupRecord>,
val messages: List<MessageResult>
) {
val isEmpty: Boolean