Reimplement contact search collection to support group access predicate.

This commit is contained in:
Alex Hart
2022-09-20 16:26:14 -03:00
committed by Cody Henthorne
parent 9dd96148d1
commit 1cea615675
10 changed files with 313 additions and 159 deletions

View File

@@ -2,11 +2,16 @@ package org.thoughtcrime.securesms.contacts.paged
import android.database.Cursor
import org.signal.paging.PagedDataSource
import org.thoughtcrime.securesms.contacts.paged.collections.ContactSearchCollection
import org.thoughtcrime.securesms.contacts.paged.collections.ContactSearchIterator
import org.thoughtcrime.securesms.contacts.paged.collections.CursorSearchIterator
import org.thoughtcrime.securesms.contacts.paged.collections.StoriesSearchCollection
import org.thoughtcrime.securesms.database.GroupDatabase.GroupRecord
import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.StorySend
import org.thoughtcrime.securesms.recipients.Recipient
import java.util.concurrent.TimeUnit
import kotlin.math.min
/**
* Manages the querying of contact information based off a configuration.
@@ -78,27 +83,30 @@ class ContactSearchPagedDataSource(
}
private fun getSectionSize(section: ContactSearchConfiguration.Section, query: String?): Int {
when (section) {
is ContactSearchConfiguration.Section.Individuals -> getNonGroupContactsCursor(section, query)
is ContactSearchConfiguration.Section.Groups -> contactSearchPagedDataSourceRepository.getGroupContacts(section, query)
is ContactSearchConfiguration.Section.Recents -> getRecentsCursor(section, query)
is ContactSearchConfiguration.Section.Stories -> getStoriesCursor(query)
}!!.use { cursor ->
val extras: List<ContactSearchData> = when (section) {
is ContactSearchConfiguration.Section.Stories -> getFilteredGroupStories(section, query)
else -> emptyList()
}
val collection = createResultsCollection(
section = section,
cursor = cursor,
extraData = extras,
cursorMapper = { error("Unsupported") }
)
return collection.getSize()
return when (section) {
is ContactSearchConfiguration.Section.Individuals -> getNonGroupSearchIterator(section, query).getCollectionSize(section, query, null)
is ContactSearchConfiguration.Section.Groups -> contactSearchPagedDataSourceRepository.getGroupSearchIterator(section, query).getCollectionSize(section, query, this::canSendToGroup)
is ContactSearchConfiguration.Section.Recents -> getRecentsSearchIterator(section, query).getCollectionSize(section, query, null)
is ContactSearchConfiguration.Section.Stories -> getStoriesSearchIterator(query).getCollectionSize(section, query, null)
}
}
private fun <R> ContactSearchIterator<R>.getCollectionSize(section: ContactSearchConfiguration.Section, query: String?, recordsPredicate: ((R) -> Boolean)?): Int {
val extras: List<ContactSearchData> = when (section) {
is ContactSearchConfiguration.Section.Stories -> getFilteredGroupStories(section, query)
else -> emptyList()
}
val collection = createResultsCollection(
section = section,
records = this,
recordsPredicate = recordsPredicate,
extraData = extras,
recordMapper = { error("Unsupported") }
)
return collection.getSize()
}
private fun getFilteredGroupStories(section: ContactSearchConfiguration.Section.Stories, query: String?): List<ContactSearchData> {
return (contactSearchPagedDataSourceRepository.getGroupStories() + section.groupStories)
.filter { contactSearchPagedDataSourceRepository.recipientNameContainsQuery(it.recipient, query) }
@@ -113,50 +121,52 @@ class ContactSearchPagedDataSource(
}
}
private fun getNonGroupContactsCursor(section: ContactSearchConfiguration.Section.Individuals, query: String?): Cursor? {
private fun getNonGroupSearchIterator(section: ContactSearchConfiguration.Section.Individuals, query: String?): ContactSearchIterator<Cursor> {
return when (section.transportType) {
ContactSearchConfiguration.TransportType.PUSH -> contactSearchPagedDataSourceRepository.querySignalContacts(query, section.includeSelf)
ContactSearchConfiguration.TransportType.SMS -> contactSearchPagedDataSourceRepository.queryNonSignalContacts(query)
ContactSearchConfiguration.TransportType.ALL -> contactSearchPagedDataSourceRepository.queryNonGroupContacts(query, section.includeSelf)
ContactSearchConfiguration.TransportType.PUSH -> CursorSearchIterator(contactSearchPagedDataSourceRepository.querySignalContacts(query, section.includeSelf))
ContactSearchConfiguration.TransportType.SMS -> CursorSearchIterator(contactSearchPagedDataSourceRepository.queryNonSignalContacts(query))
ContactSearchConfiguration.TransportType.ALL -> CursorSearchIterator(contactSearchPagedDataSourceRepository.queryNonGroupContacts(query, section.includeSelf))
}
}
private fun getStoriesCursor(query: String?): Cursor? {
return contactSearchPagedDataSourceRepository.getStories(query)
private fun getStoriesSearchIterator(query: String?): ContactSearchIterator<Cursor> {
return CursorSearchIterator(contactSearchPagedDataSourceRepository.getStories(query))
}
private fun getRecentsCursor(section: ContactSearchConfiguration.Section.Recents, query: String?): Cursor? {
private fun getRecentsSearchIterator(section: ContactSearchConfiguration.Section.Recents, query: String?): ContactSearchIterator<Cursor> {
if (!query.isNullOrEmpty()) {
throw IllegalArgumentException("Searching Recents is not supported")
}
return contactSearchPagedDataSourceRepository.getRecents(section)
return CursorSearchIterator(contactSearchPagedDataSourceRepository.getRecents(section))
}
private fun readContactDataFromCursor(
cursor: Cursor,
private fun <R> readContactData(
records: ContactSearchIterator<R>,
recordsPredicate: ((R) -> Boolean)?,
section: ContactSearchConfiguration.Section,
startIndex: Int,
endIndex: Int,
cursorRowToData: (Cursor) -> ContactSearchData,
recordMapper: (R) -> ContactSearchData,
extraData: List<ContactSearchData> = emptyList()
): List<ContactSearchData> {
val results = mutableListOf<ContactSearchData>()
val collection = createResultsCollection(section, cursor, extraData, cursorRowToData)
val collection = createResultsCollection(section, records, recordsPredicate, extraData, recordMapper)
results.addAll(collection.getSublist(startIndex, endIndex))
return results
}
private fun getStoriesContactData(section: ContactSearchConfiguration.Section.Stories, query: String?, startIndex: Int, endIndex: Int): List<ContactSearchData> {
return getStoriesCursor(query)?.use { cursor ->
readContactDataFromCursor(
cursor = cursor,
return getStoriesSearchIterator(query).use { records ->
readContactData(
records = records,
null,
section = section,
startIndex = startIndex,
endIndex = endIndex,
cursorRowToData = {
recordMapper = {
val recipient = contactSearchPagedDataSourceRepository.getRecipientFromDistributionListCursor(it)
val count = contactSearchPagedDataSourceRepository.getDistributionListMembershipCount(recipient)
val privacyMode = contactSearchPagedDataSourceRepository.getPrivacyModeFromDistributionListCursor(it)
@@ -164,155 +174,76 @@ class ContactSearchPagedDataSource(
},
extraData = getFilteredGroupStories(section, query)
)
} ?: emptyList()
}
}
private fun getRecentsContactData(section: ContactSearchConfiguration.Section.Recents, query: String?, startIndex: Int, endIndex: Int): List<ContactSearchData> {
return getRecentsCursor(section, query)?.use { cursor ->
readContactDataFromCursor(
cursor = cursor,
return getRecentsSearchIterator(section, query).use { records ->
readContactData(
records = records,
recordsPredicate = null,
section = section,
startIndex = startIndex,
endIndex = endIndex,
cursorRowToData = {
ContactSearchData.KnownRecipient(contactSearchPagedDataSourceRepository.getRecipientFromThreadCursor(cursor))
recordMapper = {
ContactSearchData.KnownRecipient(contactSearchPagedDataSourceRepository.getRecipientFromThreadCursor(it))
}
)
} ?: emptyList()
}
}
private fun getNonGroupContactsData(section: ContactSearchConfiguration.Section.Individuals, query: String?, startIndex: Int, endIndex: Int): List<ContactSearchData> {
return getNonGroupContactsCursor(section, query)?.use { cursor ->
readContactDataFromCursor(
cursor = cursor,
return getNonGroupSearchIterator(section, query).use { records ->
readContactData(
records = records,
recordsPredicate = null,
section = section,
startIndex = startIndex,
endIndex = endIndex,
cursorRowToData = {
ContactSearchData.KnownRecipient(contactSearchPagedDataSourceRepository.getRecipientFromRecipientCursor(cursor))
recordMapper = {
ContactSearchData.KnownRecipient(contactSearchPagedDataSourceRepository.getRecipientFromRecipientCursor(it))
}
)
} ?: emptyList()
}
}
private fun getGroupContactsData(section: ContactSearchConfiguration.Section.Groups, query: String?, startIndex: Int, endIndex: Int): List<ContactSearchData> {
return contactSearchPagedDataSourceRepository.getGroupContacts(section, query)?.use { cursor ->
readContactDataFromCursor(
cursor = cursor,
return contactSearchPagedDataSourceRepository.getGroupSearchIterator(section, query).use { records ->
readContactData(
records = records,
recordsPredicate = this::canSendToGroup,
section = section,
startIndex = startIndex,
endIndex = endIndex,
cursorRowToData = {
recordMapper = {
if (section.returnAsGroupStories) {
ContactSearchData.Story(contactSearchPagedDataSourceRepository.getRecipientFromGroupCursor(cursor), 0, DistributionListPrivacyMode.ALL)
ContactSearchData.Story(contactSearchPagedDataSourceRepository.getRecipientFromGroupRecord(it), 0, DistributionListPrivacyMode.ALL)
} else {
ContactSearchData.KnownRecipient(contactSearchPagedDataSourceRepository.getRecipientFromGroupCursor(cursor))
ContactSearchData.KnownRecipient(contactSearchPagedDataSourceRepository.getRecipientFromGroupRecord(it))
}
}
)
} ?: emptyList()
}
}
private fun createResultsCollection(
private fun canSendToGroup(groupRecord: GroupRecord): Boolean {
return if (groupRecord.isAnnouncementGroup) {
groupRecord.isAdmin(Recipient.self())
} else {
groupRecord.isActive
}
}
private fun <R> createResultsCollection(
section: ContactSearchConfiguration.Section,
cursor: Cursor,
records: ContactSearchIterator<R>,
recordsPredicate: ((R) -> Boolean)?,
extraData: List<ContactSearchData>,
cursorMapper: (Cursor) -> ContactSearchData
): ResultsCollection {
recordMapper: (R) -> ContactSearchData
): ContactSearchCollection<R> {
return when (section) {
is ContactSearchConfiguration.Section.Stories -> StoriesCollection(section, cursor, extraData, cursorMapper, activeStoryCount, StoryComparator(latestStorySends))
else -> ResultsCollection(section, cursor, extraData, cursorMapper, 0)
}
}
/**
* We assume that the collection is [cursor contents] + [extraData contents]
*/
private open class ResultsCollection(
val section: ContactSearchConfiguration.Section,
val cursor: Cursor,
val extraData: List<ContactSearchData>,
val cursorMapper: (Cursor) -> ContactSearchData,
val activeContactCount: Int
) {
private val contentSize = cursor.count + extraData.count()
fun getSize(): Int {
val contentsAndExpand = min(
section.expandConfig?.let {
if (it.isExpanded) Int.MAX_VALUE else (it.maxCountWhenNotExpanded(activeContactCount) + 1)
} ?: Int.MAX_VALUE,
contentSize
)
return contentsAndExpand + (if (contentsAndExpand > 0 && section.includeHeader) 1 else 0)
}
fun getSublist(start: Int, end: Int): List<ContactSearchData> {
val results = mutableListOf<ContactSearchData>()
for (i in start until end) {
results.add(getItemAt(i))
}
return results
}
private fun getItemAt(index: Int): ContactSearchData {
return when {
index == 0 && section.includeHeader -> ContactSearchData.Header(section.sectionKey, section.headerAction)
index == getSize() - 1 && shouldDisplayExpandRow() -> ContactSearchData.Expand(section.sectionKey)
else -> {
val correctedIndex = if (section.includeHeader) index - 1 else index
return getItemAtCorrectedIndex(correctedIndex)
}
}
}
protected open fun getItemAtCorrectedIndex(correctedIndex: Int): ContactSearchData {
return if (correctedIndex < cursor.count) {
cursor.moveToPosition(correctedIndex)
cursorMapper.invoke(cursor)
} else {
val extraIndex = correctedIndex - cursor.count
extraData[extraIndex]
}
}
private fun shouldDisplayExpandRow(): Boolean {
val expandConfig = section.expandConfig
return when {
expandConfig == null || expandConfig.isExpanded -> false
else -> contentSize > expandConfig.maxCountWhenNotExpanded(activeContactCount) + 1
}
}
}
private class StoriesCollection(
section: ContactSearchConfiguration.Section,
cursor: Cursor,
extraData: List<ContactSearchData>,
cursorMapper: (Cursor) -> ContactSearchData,
activeContactCount: Int,
val storyComparator: StoryComparator
) : ResultsCollection(section, cursor, extraData, cursorMapper, activeContactCount) {
private val aggregateStoryData: List<ContactSearchData.Story> by lazy {
if (section !is ContactSearchConfiguration.Section.Stories) {
error("Aggregate data creation is only necessary for stories.")
}
val cursorContacts: List<ContactSearchData> = (0 until cursor.count).map {
cursor.moveToPosition(it)
cursorMapper(cursor)
}
(cursorContacts + extraData)
.filterIsInstance(ContactSearchData.Story::class.java)
.sortedWith(storyComparator)
}
override fun getItemAtCorrectedIndex(correctedIndex: Int): ContactSearchData {
return aggregateStoryData[correctedIndex]
is ContactSearchConfiguration.Section.Stories -> StoriesSearchCollection(section, records, extraData, recordMapper, activeStoryCount, StoryComparator(latestStorySends))
else -> ContactSearchCollection(section, records, recordsPredicate, extraData, recordMapper, 0)
}
}

View File

@@ -5,8 +5,10 @@ import android.database.Cursor
import org.signal.core.util.CursorUtil
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.ContactRepository
import org.thoughtcrime.securesms.contacts.paged.collections.ContactSearchIterator
import org.thoughtcrime.securesms.database.DistributionListDatabase
import org.thoughtcrime.securesms.database.GroupDatabase
import org.thoughtcrime.securesms.database.GroupDatabase.GroupRecord
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode
@@ -42,10 +44,10 @@ open class ContactSearchPagedDataSourceRepository(
return contactRepository.queryNonGroupContacts(query ?: "", includeSelf)
}
open fun getGroupContacts(
open fun getGroupSearchIterator(
section: ContactSearchConfiguration.Section.Groups,
query: String?
): Cursor? {
): ContactSearchIterator<GroupRecord> {
return SignalDatabase.groups.queryGroups(
GroupDatabase.GroupQuery.Builder()
.withSearchQuery(query)
@@ -54,7 +56,7 @@ open class ContactSearchPagedDataSourceRepository(
.withV1Groups(section.includeV1)
.withSortOrder(section.sortOrder)
.build()
).cursor
)
}
open fun getRecents(section: ContactSearchConfiguration.Section.Recents): Cursor? {
@@ -89,8 +91,8 @@ open class ContactSearchPagedDataSourceRepository(
return Recipient.resolved(RecipientId.from(CursorUtil.requireLong(cursor, ContactRepository.ID_COLUMN)))
}
open fun getRecipientFromGroupCursor(cursor: Cursor): Recipient {
return Recipient.resolved(RecipientId.from(CursorUtil.requireLong(cursor, GroupDatabase.RECIPIENT_ID)))
open fun getRecipientFromGroupRecord(groupRecord: GroupRecord): Recipient {
return Recipient.resolved(groupRecord.recipientId)
}
open fun getDistributionListMembershipCount(recipient: Recipient): Int {

View File

@@ -0,0 +1,125 @@
package org.thoughtcrime.securesms.contacts.paged.collections
import androidx.collection.SparseArrayCompat
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
import org.thoughtcrime.securesms.contacts.paged.ContactSearchData
import kotlin.math.min
/**
* Generic contact search collection.
*/
open class ContactSearchCollection<ContactRecord>(
private val section: ContactSearchConfiguration.Section,
private val records: ContactSearchIterator<ContactRecord>,
private val recordPredicate: ((ContactRecord) -> Boolean)? = null,
private val extraData: List<ContactSearchData>,
private val recordMapper: (ContactRecord) -> ContactSearchData,
private val activeContactCount: Int
) {
private val recordsCount: Int = if (recordPredicate != null) {
records.asSequence().filter(recordPredicate).count()
} else {
records.getCount()
}
private val contentSize: Int
private val aggregateData: SparseArrayCompat<ContactSearchData> = SparseArrayCompat()
init {
records.moveToPosition(-1)
contentSize = recordsCount + extraData.count()
}
fun getSize(): Int {
val contentMaximum = section.expandConfig?.let {
if (it.isExpanded) Int.MAX_VALUE else (it.maxCountWhenNotExpanded(activeContactCount) + 1)
} ?: Int.MAX_VALUE
val contentAndExpanded = min(contentMaximum, contentSize)
return contentAndExpanded + (if (contentAndExpanded > 0 && section.includeHeader) 1 else 0)
}
fun getSublist(start: Int, end: Int): List<ContactSearchData> {
val results = mutableListOf<ContactSearchData>()
val startOffset = if (start == 0 && section.includeHeader) {
results.add(ContactSearchData.Header(section.sectionKey, section.headerAction))
1
} else {
0
}
val (expand, endOffset) = if (end == getSize() && shouldDisplayExpandRow()) {
ContactSearchData.Expand(section.sectionKey) to 1
} else {
null to 0
}
fillDataWindow(start, end - start)
for (i in (start + startOffset) until (end - endOffset)) {
val correctedIndex = if (section.includeHeader) i - 1 else i
results.add(getItemAtCorrectedIndex(correctedIndex))
}
if (expand != null) {
results.add(expand)
}
return results
}
open fun getItemAtCorrectedIndex(correctedIndex: Int): ContactSearchData {
return if (recordPredicate == null) {
records.moveToPosition(correctedIndex - 1)
recordMapper.invoke(records.next())
} else {
aggregateData.get(correctedIndex)!!
}
}
open fun fillDataWindow(offset: Int, limit: Int) {
if (recordPredicate == null) {
return
}
if (isAggregateDataFilled(offset, limit)) {
return
}
var key = offset
records.moveToPosition(-1)
records.asSequence().filter(recordPredicate).drop(offset).take(limit).forEach {
aggregateData.put(key, recordMapper.invoke(it))
key++
}
if (isAggregateDataFilled(offset, limit)) {
return
}
extraData.forEach {
aggregateData.put(key, it)
key++
}
if (isAggregateDataFilled(offset, limit)) {
return
}
throw IllegalStateException("Could not fill aggregate data for bounds $offset $limit")
}
private fun isAggregateDataFilled(startOffset: Int, limit: Int): Boolean {
return (startOffset until (startOffset + limit)).all { aggregateData.containsKey(it) }
}
private fun shouldDisplayExpandRow(): Boolean {
val expandConfig = section.expandConfig
return when {
expandConfig == null || expandConfig.isExpanded -> false
else -> contentSize > expandConfig.maxCountWhenNotExpanded(activeContactCount) + 1
}
}
}

View File

@@ -0,0 +1,12 @@
package org.thoughtcrime.securesms.contacts.paged.collections
import java.io.Closeable
/**
* Describes the required interface for the ContactSearchPagedDataSource to pull
* and filter the information it needs from the database.
*/
interface ContactSearchIterator<ContactRecord> : Iterator<ContactRecord>, Closeable {
fun moveToPosition(n: Int)
fun getCount(): Int
}

View File

@@ -0,0 +1,22 @@
package org.thoughtcrime.securesms.contacts.paged.collections
import android.database.Cursor
class CursorSearchIterator(private val cursor: Cursor?) : ContactSearchIterator<Cursor> {
override fun hasNext(): Boolean = cursor?.let { !it.isLast && !it.isAfterLast } ?: false
override fun next(): Cursor {
cursor?.moveToNext()
return cursor!!
}
override fun close() {
cursor?.close()
}
override fun moveToPosition(n: Int) {
cursor?.moveToPosition(n)
}
override fun getCount(): Int = cursor?.count ?: 0
}

View File

@@ -0,0 +1,32 @@
package org.thoughtcrime.securesms.contacts.paged.collections
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
import org.thoughtcrime.securesms.contacts.paged.ContactSearchData
/**
* Search collection specifically for stories.
*/
class StoriesSearchCollection<ContactRecord>(
section: ContactSearchConfiguration.Section,
records: ContactSearchIterator<ContactRecord>,
extraData: List<ContactSearchData>,
recordMapper: (ContactRecord) -> ContactSearchData,
activeContactCount: Int,
private val storyComparator: Comparator<ContactSearchData.Story>
) : ContactSearchCollection<ContactRecord>(section, records, null, extraData, recordMapper, activeContactCount) {
private val aggregateStoryData: List<ContactSearchData.Story> by lazy {
if (section !is ContactSearchConfiguration.Section.Stories) {
error("Aggregate data creation is only necessary for stories.")
}
val cursorContacts = records.asSequence().map(recordMapper).toList()
(cursorContacts + extraData).filterIsInstance(ContactSearchData.Story::class.java).sortedWith(storyComparator)
}
override fun getItemAtCorrectedIndex(correctedIndex: Int): ContactSearchData {
return aggregateStoryData[correctedIndex]
}
override fun fillDataWindow(offset: Int, limit: Int) = Unit
}

View File

@@ -25,6 +25,7 @@ import org.signal.storageservice.protos.groups.local.DecryptedGroup;
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
import org.signal.storageservice.protos.groups.local.EnabledState;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchSortOrder;
import org.thoughtcrime.securesms.contacts.paged.collections.ContactSearchIterator;
import org.thoughtcrime.securesms.crypto.SenderKeyUtil;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.groups.BadGroupIdException;
@@ -1049,7 +1050,7 @@ public class GroupDatabase extends Database {
return result;
}
public static class Reader implements Closeable {
public static class Reader implements Closeable, ContactSearchIterator<GroupRecord> {
public final Cursor cursor;
@@ -1101,6 +1102,21 @@ public class GroupDatabase extends Database {
if (this.cursor != null)
this.cursor.close();
}
@Override
public void moveToPosition(int n) {
cursor.moveToPosition(n);
}
@Override
public boolean hasNext() {
return !cursor.isLast() && !cursor.isAfterLast();
}
@Override
public GroupRecord next() {
return getNext();
}
}
public static class GroupRecord {

View File

@@ -252,6 +252,10 @@ public final class PushGroupSendJob extends PushSendJob {
if (message.getStoryType().isStory()) {
Optional<GroupDatabase.GroupRecord> groupRecord = SignalDatabase.groups().getGroup(groupId);
if (groupRecord.isPresent() && groupRecord.get().isAnnouncementGroup() && !groupRecord.get().isAdmin(Recipient.self())) {
throw new UndeliverableMessageException("Non-admins cannot send stories in announcement groups!");
}
if (groupRecord.isPresent()) {
GroupDatabase.V2GroupProperties v2GroupProperties = groupRecord.get().requireV2GroupProperties();
SignalServiceGroupV2 groupContext = SignalServiceGroupV2.newBuilder(v2GroupProperties.getGroupMasterKey())

View File

@@ -28,4 +28,12 @@ abstract class MockCursor : Cursor {
return true
}
override fun isLast(): Boolean {
return _position == count - 1
}
override fun isAfterLast(): Boolean {
return _position >= count
}
}

View File

@@ -24,7 +24,7 @@ class ContactSearchPagedDataSourceTest {
@Before
fun setUp() {
whenever(repository.getRecipientFromGroupCursor(cursor)).thenReturn(Recipient.UNKNOWN)
whenever(repository.getRecipientFromGroupRecord(any())).thenReturn(Recipient.UNKNOWN)
whenever(repository.getRecipientFromRecipientCursor(cursor)).thenReturn(Recipient.UNKNOWN)
whenever(repository.getRecipientFromThreadCursor(cursor)).thenReturn(Recipient.UNKNOWN)
whenever(repository.getRecipientFromDistributionListCursor(cursor)).thenReturn(Recipient.UNKNOWN)
@@ -34,6 +34,8 @@ class ContactSearchPagedDataSourceTest {
whenever(cursor.moveToPosition(any())).thenCallRealMethod()
whenever(cursor.moveToNext()).thenCallRealMethod()
whenever(cursor.position).thenCallRealMethod()
whenever(cursor.isLast).thenCallRealMethod()
whenever(cursor.isAfterLast).thenCallRealMethod()
}
@Test