Add a global search (#834)

* feat: modifying search functionalities to include contacts

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

* feat: add global search repository and model content

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

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

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

* fix: compile error from merge

* fix: gradlew build errors

* feat: filtering contacts by existing un-archived threads

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

* feat: adding home screen new redesigns for search

* feat: replacing designs and adding new group subtitle text

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

* feat: add scrollbars for search

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

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

* refactor: remove visibility modifiers for cancel icon

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

* fix: use threadId instead of group's address

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

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

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

* refactor: use dynamic limit

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

* fix: build lint errors

* fix: build issues

* feat: add in path to the settings activity

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

View File

@ -130,6 +130,7 @@ dependencies {
testImplementation 'androidx.test:core:1.3.0'
testImplementation "androidx.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

View File

@ -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" />

View File

@ -15,6 +15,7 @@ import androidx.test.platform.app.InstrumentationRegistry
import network.loki.messenger.util.InputBarButtonDrawableMatcher.Companion.inputButtonWithDrawable
import network.loki.messenger.util.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

View File

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

View File

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

View File

@ -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()

View File

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

View File

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

View File

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

View File

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

View File

@ -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<>();

View File

@ -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

View File

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

View File

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

View File

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

View File

@ -339,6 +339,19 @@ public class ThreadDatabase extends Database {
}
public Cursor searchConversationAddresses(String addressQuery) {
if (addressQuery == null || addressQuery.isEmpty()) {
return null;
}
SQLiteDatabase db = databaseHelper.getReadableDatabase();
String selection = TABLE_NAME + "." + ADDRESS + " LIKE ? AND " + TABLE_NAME + "." + MESSAGE_COUNT + " != 0";
String[] selectionArgs = new String[]{addressQuery+"%"};
String query = createQuery(selection, 0);
Cursor cursor = db.rawQuery(query, selectionArgs);
return cursor;
}
public Cursor getFilteredConversationList(@Nullable List<Address> filter) {
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;

View File

@ -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)

View File

@ -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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -105,7 +105,7 @@ public class MediaPickerItemFragment extends Fragment implements MediaPickerItem
onMediaSelectionChanged(new ArrayList<>(viewModel.getSelectedMedia().getValue()));
}
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();
});
}

View File

@ -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()

View File

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

View File

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

View File

@ -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"

View File

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

View File

@ -3,29 +3,39 @@ package org.thoughtcrime.securesms.search;
import android.content.Context;
import android.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.
*/
@ -53,18 +63,24 @@ public class SearchRepository {
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<Recipient> contacts = queryContacts(cleanQuery);
Pair<CursorList<Contact>, List<String>> contacts = queryContacts(cleanQuery);
timer.split("contacts");
CursorList<ThreadRecord> conversations = queryConversations(cleanQuery);
CursorList<GroupRecord> conversations = queryConversations(cleanQuery, contacts.getSecond());
timer.split("conversations");
CursorList<MessageResult> 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,27 +127,61 @@ public class SearchRepository {
});
}
private CursorList<Recipient> 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<CursorList<Contact>, List<String>> queryContacts(String query) {
Cursor contacts = contactDatabase.queryContactsByName(query);
List<Address> contactList = new ArrayList<>();
List<String> contactStrings = new ArrayList<>();
while (contacts.moveToNext()) {
try {
Contact contact = contactDatabase.contactFromCursor(contacts);
String contactSessionId = contact.getSessionID();
Address address = Address.fromSerialized(contactSessionId);
contactList.add(address);
contactStrings.add(contactSessionId);
} catch (Exception e) {
Log.e("Loki", "Error building Contact from cursor in query", e);
}
}
Cursor textSecureContacts = contactsDatabase.queryTextSecureContacts(query);
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<ThreadRecord> queryConversations(@NonNull String query) {
private CursorList<GroupRecord> queryConversations(@NonNull String query, List<String> matchingAddresses) {
List<String> numbers = contactAccessor.getNumbersForThreadSearchFilter(context, query);
List<Address> addresses = Stream.of(numbers).map(number -> Address.fromExternal(context, number)).toList();
String localUserNumber = TextSecurePreferences.getLocalNumber(context);
if (localUserNumber != null) {
matchingAddresses.remove(localUserNumber);
}
Set<Address> addresses = new HashSet<>(Stream.of(numbers).map(number -> Address.fromExternal(context, number)).toList());
Cursor conversations = threadDatabase.getFilteredConversationList(addresses);
return conversations != null ? new CursorList<>(conversations, new ThreadModelBuilder(threadDatabase))
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();
}
@ -169,6 +219,28 @@ public class SearchRepository {
return out.toString();
}
private static class ContactModelBuilder implements CursorList.ModelBuilder<Contact> {
private final SessionContactDatabase contactDb;
private final ThreadDatabase threadDb;
public ContactModelBuilder(SessionContactDatabase contactDb, ThreadDatabase threadDb) {
this.contactDb = contactDb;
this.threadDb = threadDb;
}
@Override
public Contact build(@NonNull Cursor cursor) {
ThreadRecord threadRecord = threadDb.readerFor(cursor).getCurrent();
Contact contact = contactDb.getContactWithSessionID(threadRecord.getRecipient().getAddress().serialize());
if (contact == null) {
contact = new Contact(threadRecord.getRecipient().getAddress().serialize());
contact.setThreadID(threadRecord.getThreadId());
}
return contact;
}
}
private static class RecipientModelBuilder implements CursorList.ModelBuilder<Recipient> {
private final Context context;
@ -184,6 +256,22 @@ public class SearchRepository {
}
}
private static class GroupModelBuilder implements CursorList.ModelBuilder<GroupRecord> {
private final ThreadDatabase threadDatabase;
private final GroupDatabase groupDatabase;
public GroupModelBuilder(ThreadDatabase threadDatabase, GroupDatabase groupDatabase) {
this.threadDatabase = threadDatabase;
this.groupDatabase = groupDatabase;
}
@Override
public GroupRecord build(@NonNull Cursor cursor) {
ThreadRecord threadRecord = threadDatabase.readerFor(cursor).getCurrent();
return groupDatabase.getGroup(threadRecord.getRecipient().getAddress().toGroupString()).get();
}
}
private static class ThreadModelBuilder implements CursorList.ModelBuilder<ThreadRecord> {
private 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);

View File

@ -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<Recipient> contacts;
private final CursorList<ThreadRecord> conversations;
private final CursorList<Contact> contacts;
private final CursorList<GroupRecord> conversations;
private final CursorList<MessageResult> messages;
public SearchResult(@NonNull String query,
@NonNull CursorList<Recipient> contacts,
@NonNull CursorList<ThreadRecord> conversations,
@NonNull CursorList<Contact> contacts,
@NonNull CursorList<GroupRecord> conversations,
@NonNull CursorList<MessageResult> messages)
{
this.query = query;
@ -34,11 +35,11 @@ public class SearchResult {
this.messages = messages;
}
public List<Recipient> getContacts() {
public List<Contact> getContacts() {
return contacts;
}
public List<ThreadRecord> getConversations() {
public List<GroupRecord> getConversations() {
return conversations;
}

View File

@ -229,7 +229,7 @@ object BackupUtil {
@JvmOverloads
fun deleteAllBackupFiles(context: Context, except: Collection<BackupFileRecord>? = 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

View File

@ -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());

View File

@ -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
}
}

View File

@ -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;

View File

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

View File

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

View File

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

Binary file not shown.

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent"
@ -30,7 +30,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"/>
</FrameLayout>

View File

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

View File

@ -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"/>
</FrameLayout>

View File

@ -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"

View File

@ -1,10 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<ImageView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/remove_image_button"
android: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"
android:tint="#99FFFFFF"
app:tint="#99FFFFFF"
android:visibility="gone" />

View File

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_marginEnd="2dp"
android:layout_marginBottom="2dp">
@ -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"/>
<TextView

View File

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

View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
@ -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"/>
</LinearLayout>
@ -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"

View File

@ -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"/>
</FrameLayout>

View File

@ -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"/>
<TextView
android:id="@+id/quote_missing_text"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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