Apply new story list ordering rules.
Co-authored-by: Cody Henthorne <cody@signal.org>
This commit is contained in:
parent
3b07f4a8ca
commit
88a66b49ff
16 changed files with 319 additions and 77 deletions
|
@ -0,0 +1,6 @@
|
|||
package org.thoughtcrime.securesms.contacts.paged
|
||||
|
||||
/**
|
||||
* Number of active contacts for a given section, handed to the expand config.
|
||||
*/
|
||||
typealias ActiveContactCount = Int
|
|
@ -83,7 +83,7 @@ class ContactSearchConfiguration private constructor(
|
|||
*/
|
||||
data class ExpandConfig(
|
||||
val isExpanded: Boolean,
|
||||
val maxCountWhenNotExpanded: Int = 2
|
||||
val maxCountWhenNotExpanded: (ActiveContactCount) -> Int = { 2 }
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
|
@ -3,6 +3,8 @@ package org.thoughtcrime.securesms.contacts.paged
|
|||
import android.database.Cursor
|
||||
import org.signal.paging.PagedDataSource
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.StorySend
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
|
@ -13,6 +15,14 @@ class ContactSearchPagedDataSource(
|
|||
private val contactSearchPagedDataSourceRepository: ContactSearchPagedDataSourceRepository = ContactSearchPagedDataSourceRepository(ApplicationDependencies.getApplication())
|
||||
) : PagedDataSource<ContactSearchKey, ContactSearchData> {
|
||||
|
||||
companion object {
|
||||
private val ACTIVE_STORY_CUTOFF_DURATION = TimeUnit.DAYS.toMillis(1)
|
||||
}
|
||||
|
||||
private val latestStorySends: List<StorySend> = contactSearchPagedDataSourceRepository.getLatestStorySends(ACTIVE_STORY_CUTOFF_DURATION)
|
||||
|
||||
private val activeStoryCount = latestStorySends.size
|
||||
|
||||
override fun size(): Int {
|
||||
return contactConfiguration.sections.sumOf {
|
||||
getSectionSize(it, contactConfiguration.query)
|
||||
|
@ -67,26 +77,25 @@ class ContactSearchPagedDataSource(
|
|||
}
|
||||
|
||||
private fun getSectionSize(section: ContactSearchConfiguration.Section, query: String?): Int {
|
||||
val cursor: Cursor = when (section) {
|
||||
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 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()
|
||||
}
|
||||
|
||||
val collection = ResultsCollection(
|
||||
section = section,
|
||||
cursor = cursor,
|
||||
extraData = extras,
|
||||
cursorMapper = { error("Unsupported") }
|
||||
)
|
||||
|
||||
return collection.getSize()
|
||||
}
|
||||
|
||||
private fun getFilteredGroupStories(section: ContactSearchConfiguration.Section.Stories, query: String?): List<ContactSearchData> {
|
||||
|
@ -133,7 +142,7 @@ class ContactSearchPagedDataSource(
|
|||
): List<ContactSearchData> {
|
||||
val results = mutableListOf<ContactSearchData>()
|
||||
|
||||
val collection = ResultsCollection(section, cursor, extraData, cursorRowToData)
|
||||
val collection = createResultsCollection(section, cursor, extraData, cursorRowToData)
|
||||
results.addAll(collection.getSublist(startIndex, endIndex))
|
||||
|
||||
return results
|
||||
|
@ -201,14 +210,27 @@ class ContactSearchPagedDataSource(
|
|||
} ?: emptyList()
|
||||
}
|
||||
|
||||
private fun createResultsCollection(
|
||||
section: ContactSearchConfiguration.Section,
|
||||
cursor: Cursor,
|
||||
extraData: List<ContactSearchData>,
|
||||
cursorMapper: (Cursor) -> ContactSearchData
|
||||
): ResultsCollection {
|
||||
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 data class ResultsCollection(
|
||||
private open class ResultsCollection(
|
||||
val section: ContactSearchConfiguration.Section,
|
||||
val cursor: Cursor,
|
||||
val extraData: List<ContactSearchData>,
|
||||
val cursorMapper: (Cursor) -> ContactSearchData
|
||||
val cursorMapper: (Cursor) -> ContactSearchData,
|
||||
val activeContactCount: Int
|
||||
) {
|
||||
|
||||
private val contentSize = cursor.count + extraData.count()
|
||||
|
@ -216,7 +238,7 @@ class ContactSearchPagedDataSource(
|
|||
fun getSize(): Int {
|
||||
val contentsAndExpand = min(
|
||||
section.expandConfig?.let {
|
||||
if (it.isExpanded) Int.MAX_VALUE else (it.maxCountWhenNotExpanded + 1)
|
||||
if (it.isExpanded) Int.MAX_VALUE else (it.maxCountWhenNotExpanded(activeContactCount) + 1)
|
||||
} ?: Int.MAX_VALUE,
|
||||
contentSize
|
||||
)
|
||||
|
@ -239,22 +261,74 @@ class ContactSearchPagedDataSource(
|
|||
index == getSize() - 1 && shouldDisplayExpandRow() -> ContactSearchData.Expand(section.sectionKey)
|
||||
else -> {
|
||||
val correctedIndex = if (section.includeHeader) index - 1 else index
|
||||
if (correctedIndex < cursor.count) {
|
||||
cursor.moveToPosition(correctedIndex)
|
||||
cursorMapper.invoke(cursor)
|
||||
} else {
|
||||
val extraIndex = correctedIndex - cursor.count
|
||||
extraData[extraIndex]
|
||||
}
|
||||
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 + 1
|
||||
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]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* StoryComparator
|
||||
*/
|
||||
private class StoryComparator(private val latestStorySends: List<StorySend>) : Comparator<ContactSearchData.Story> {
|
||||
override fun compare(lhs: ContactSearchData.Story, rhs: ContactSearchData.Story): Int {
|
||||
val lhsActiveRank = latestStorySends.indexOfFirst { it.identifier.matches(lhs.recipient) }.let { if (it == -1) Int.MAX_VALUE else it }
|
||||
val rhsActiveRank = latestStorySends.indexOfFirst { it.identifier.matches(rhs.recipient) }.let { if (it == -1) Int.MAX_VALUE else it }
|
||||
|
||||
return when {
|
||||
lhs.recipient.isMyStory && rhs.recipient.isMyStory -> 0
|
||||
lhs.recipient.isMyStory -> -1
|
||||
rhs.recipient.isMyStory -> 1
|
||||
lhsActiveRank < rhsActiveRank -> -1
|
||||
lhsActiveRank > rhsActiveRank -> 1
|
||||
lhsActiveRank == rhsActiveRank -> -1
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,6 +9,8 @@ import org.thoughtcrime.securesms.database.DistributionListDatabase
|
|||
import org.thoughtcrime.securesms.database.GroupDatabase
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.keyvalue.StorySend
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
||||
|
@ -22,6 +24,11 @@ open class ContactSearchPagedDataSourceRepository(
|
|||
|
||||
private val contactRepository = ContactRepository(context, context.getString(R.string.note_to_self))
|
||||
|
||||
open fun getLatestStorySends(activeStoryCutoffDuration: Long): List<StorySend> {
|
||||
return SignalStore.storyValues()
|
||||
.getLatestActiveStorySendTimestamps(System.currentTimeMillis() - activeStoryCutoffDuration)
|
||||
}
|
||||
|
||||
open fun querySignalContacts(query: String?, includeSelf: Boolean): Cursor? {
|
||||
return contactRepository.querySignalContacts(query ?: "", includeSelf)
|
||||
}
|
||||
|
|
|
@ -354,7 +354,8 @@ class MultiselectForwardFragment :
|
|||
if (Stories.isFeatureEnabled() && isSelectedMediaValidForStories()) {
|
||||
val expandedConfig: ContactSearchConfiguration.ExpandConfig? = if (isSelectedMediaValidForNonStories()) {
|
||||
ContactSearchConfiguration.ExpandConfig(
|
||||
isExpanded = contactSearchState.expandedSections.contains(ContactSearchConfiguration.SectionKey.STORIES)
|
||||
isExpanded = contactSearchState.expandedSections.contains(ContactSearchConfiguration.SectionKey.STORIES),
|
||||
maxCountWhenNotExpanded = { it + 1 }
|
||||
)
|
||||
} else {
|
||||
null
|
||||
|
|
|
@ -35,6 +35,15 @@ public final class DistributionListId implements DatabaseId, Parcelable {
|
|||
}
|
||||
}
|
||||
|
||||
public static @NonNull DistributionListId from(@NonNull String serializedId) {
|
||||
try {
|
||||
long id = Long.parseLong(serializedId);
|
||||
return from(id);
|
||||
} catch (NumberFormatException e) {
|
||||
throw new IllegalArgumentException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private DistributionListId(long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
|
|
@ -85,11 +85,11 @@ public final class PushGroupSendJob extends PushSendJob {
|
|||
|
||||
public PushGroupSendJob(long messageId, @NonNull RecipientId destination, @NonNull Set<RecipientId> filterRecipients, boolean hasMedia) {
|
||||
this(new Job.Parameters.Builder()
|
||||
.setQueue(destination.toQueueKey(hasMedia))
|
||||
.addConstraint(NetworkConstraint.KEY)
|
||||
.setLifespan(TimeUnit.DAYS.toMillis(1))
|
||||
.setMaxAttempts(Parameters.UNLIMITED)
|
||||
.build(),
|
||||
.setQueue(destination.toQueueKey(hasMedia))
|
||||
.addConstraint(NetworkConstraint.KEY)
|
||||
.setLifespan(TimeUnit.DAYS.toMillis(1))
|
||||
.setMaxAttempts(Parameters.UNLIMITED)
|
||||
.build(),
|
||||
messageId, filterRecipients);
|
||||
|
||||
}
|
||||
|
@ -171,7 +171,7 @@ public final class PushGroupSendJob extends PushSendJob {
|
|||
ApplicationDependencies.getJobManager().cancelAllInQueue(TypingSendJob.getQueue(threadId));
|
||||
|
||||
if (database.isSent(messageId)) {
|
||||
log(TAG, String.valueOf(message.getSentTimeMillis()), "Message " + messageId + " was already sent. Ignoring.");
|
||||
log(TAG, String.valueOf(message.getSentTimeMillis()), "Message " + messageId + " was already sent. Ignoring.");
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -246,21 +246,22 @@ public final class PushGroupSendJob extends PushSendJob {
|
|||
List<SignalServiceDataMessage.Mention> mentions = getMentionsFor(message.getMentions());
|
||||
List<Attachment> attachments = Stream.of(message.getAttachments()).filterNot(Attachment::isSticker).toList();
|
||||
List<SignalServiceAttachment> attachmentPointers = getAttachmentPointersFor(attachments);
|
||||
boolean isRecipientUpdate = Stream.of(SignalDatabase.groupReceipts().getGroupReceiptInfo(messageId))
|
||||
.anyMatch(info -> info.getStatus() > GroupReceiptDatabase.STATUS_UNDELIVERED);
|
||||
boolean isRecipientUpdate = Stream.of(SignalDatabase.groupReceipts().getGroupReceiptInfo(messageId))
|
||||
.anyMatch(info -> info.getStatus() > GroupReceiptDatabase.STATUS_UNDELIVERED);
|
||||
|
||||
if (message.getStoryType().isStory()) {
|
||||
Optional<GroupDatabase.GroupRecord> groupRecord = SignalDatabase.groups().getGroup(groupId);
|
||||
|
||||
if (groupRecord.isPresent()) {
|
||||
GroupDatabase.V2GroupProperties v2GroupProperties = groupRecord.get().requireV2GroupProperties();
|
||||
SignalServiceGroupV2 groupContext = SignalServiceGroupV2.newBuilder(v2GroupProperties.getGroupMasterKey())
|
||||
.withRevision(v2GroupProperties.getGroupRevision())
|
||||
.build();
|
||||
SignalServiceGroupV2 groupContext = SignalServiceGroupV2.newBuilder(v2GroupProperties.getGroupMasterKey())
|
||||
.withRevision(v2GroupProperties.getGroupRevision())
|
||||
.build();
|
||||
|
||||
final SignalServiceStoryMessage storyMessage;
|
||||
if (message.getStoryType().isTextStory()) {
|
||||
storyMessage = SignalServiceStoryMessage.forTextAttachment(Recipient.self().getProfileKey(), groupContext, StorySendUtil.deserializeBodyToStoryTextAttachment(message, this::getPreviewsFor), message.getStoryType().isStoryWithReplies());
|
||||
storyMessage = SignalServiceStoryMessage.forTextAttachment(Recipient.self().getProfileKey(), groupContext, StorySendUtil.deserializeBodyToStoryTextAttachment(message, this::getPreviewsFor), message.getStoryType()
|
||||
.isStoryWithReplies());
|
||||
} else if (!attachmentPointers.isEmpty()) {
|
||||
storyMessage = SignalServiceStoryMessage.forFileAttachment(Recipient.self().getProfileKey(), groupContext, attachmentPointers.get(0), message.getStoryType().isStoryWithReplies());
|
||||
} else {
|
||||
|
@ -277,15 +278,15 @@ public final class PushGroupSendJob extends PushSendJob {
|
|||
if (groupMessage.isV2Group()) {
|
||||
MessageGroupContext.GroupV2Properties properties = groupMessage.requireGroupV2Properties();
|
||||
GroupContextV2 groupContext = properties.getGroupContext();
|
||||
SignalServiceGroupV2.Builder builder = SignalServiceGroupV2.newBuilder(properties.getGroupMasterKey())
|
||||
.withRevision(groupContext.getRevision());
|
||||
SignalServiceGroupV2.Builder builder = SignalServiceGroupV2.newBuilder(properties.getGroupMasterKey())
|
||||
.withRevision(groupContext.getRevision());
|
||||
|
||||
ByteString groupChange = groupContext.getGroupChange();
|
||||
if (groupChange != null) {
|
||||
builder.withSignedGroupChange(groupChange.toByteArray());
|
||||
}
|
||||
|
||||
SignalServiceGroupV2 group = builder.build();
|
||||
SignalServiceGroupV2 group = builder.build();
|
||||
SignalServiceDataMessage groupDataMessage = SignalServiceDataMessage.newBuilder()
|
||||
.withTimestamp(message.getSentTimeMillis())
|
||||
.withExpiration(groupRecipient.getExpiresInSeconds())
|
||||
|
@ -309,7 +310,7 @@ public final class PushGroupSendJob extends PushSendJob {
|
|||
|
||||
SignalServiceDataMessage.Builder groupMessageBuilder = builder.withAttachments(attachmentPointers)
|
||||
.withBody(message.getBody())
|
||||
.withExpiration((int)(message.getExpiresIn() / 1000))
|
||||
.withExpiration((int) (message.getExpiresIn() / 1000))
|
||||
.withViewOnce(message.isViewOnce())
|
||||
.asExpirationUpdate(message.isExpirationUpdate())
|
||||
.withProfileKey(profileKey.orElse(null))
|
||||
|
@ -377,7 +378,8 @@ public final class PushGroupSendJob extends PushSendJob {
|
|||
RecipientAccessList accessList = new RecipientAccessList(target);
|
||||
|
||||
List<NetworkFailure> networkFailures = Stream.of(results).filter(SendMessageResult::isNetworkFailure).map(result -> new NetworkFailure(accessList.requireIdByAddress(result.getAddress()))).toList();
|
||||
List<IdentityKeyMismatch> identityMismatches = Stream.of(results).filter(result -> result.getIdentityFailure() != null).map(result -> new IdentityKeyMismatch(accessList.requireIdByAddress(result.getAddress()), result.getIdentityFailure().getIdentityKey())).toList();
|
||||
List<IdentityKeyMismatch> identityMismatches = Stream.of(results).filter(result -> result.getIdentityFailure() != null)
|
||||
.map(result -> new IdentityKeyMismatch(accessList.requireIdByAddress(result.getAddress()), result.getIdentityFailure().getIdentityKey())).toList();
|
||||
ProofRequiredException proofRequired = Stream.of(results).filter(r -> r.getProofRequiredFailure() != null).findLast().map(SendMessageResult::getProofRequiredFailure).orElse(null);
|
||||
List<SendMessageResult> successes = Stream.of(results).filter(result -> result.getSuccess() != null).toList();
|
||||
List<Pair<RecipientId, Boolean>> successUnidentifiedStatus = Stream.of(successes).map(result -> new Pair<>(accessList.requireIdByAddress(result.getAddress()), result.getSuccess().isUnidentified())).toList();
|
||||
|
@ -390,8 +392,8 @@ public final class PushGroupSendJob extends PushSendJob {
|
|||
skippedRecipients.addAll(skipped);
|
||||
|
||||
if (networkFailures.size() > 0 || identityMismatches.size() > 0 || proofRequired != null || unregisteredRecipients.size() > 0) {
|
||||
Log.w(TAG, String.format(Locale.US, "Failed to send to some recipients. Network: %d, Identity: %d, ProofRequired: %s, Unregistered: %d",
|
||||
networkFailures.size(), identityMismatches.size(), proofRequired != null, unregisteredRecipients.size()));
|
||||
Log.w(TAG, String.format(Locale.US, "Failed to send to some recipients. Network: %d, Identity: %d, ProofRequired: %s, Unregistered: %d",
|
||||
networkFailures.size(), identityMismatches.size(), proofRequired != null, unregisteredRecipients.size()));
|
||||
}
|
||||
|
||||
RecipientDatabase recipientDatabase = SignalDatabase.recipients();
|
||||
|
@ -459,7 +461,7 @@ public final class PushGroupSendJob extends PushSendJob {
|
|||
private static @NonNull GroupRecipientResult getGroupMessageRecipients(@NonNull GroupId groupId, long messageId) {
|
||||
List<GroupReceiptInfo> destinations = SignalDatabase.groupReceipts().getGroupReceiptInfo(messageId);
|
||||
|
||||
List<Recipient> possible;
|
||||
List<Recipient> possible;
|
||||
|
||||
if (!destinations.isEmpty()) {
|
||||
possible = Stream.of(destinations)
|
||||
|
@ -471,9 +473,9 @@ public final class PushGroupSendJob extends PushSendJob {
|
|||
Log.w(TAG, "No destinations found for group message " + groupId + " using current group membership");
|
||||
possible = Stream.of(SignalDatabase.groups()
|
||||
.getGroupMembers(groupId, GroupDatabase.MemberSet.FULL_MEMBERS_EXCLUDING_SELF))
|
||||
.map(Recipient::resolve)
|
||||
.distinctBy(Recipient::getId)
|
||||
.toList();
|
||||
.map(Recipient::resolve)
|
||||
.distinctBy(Recipient::getId)
|
||||
.toList();
|
||||
}
|
||||
|
||||
List<Recipient> eligible = RecipientUtil.getEligibleForSending(possible);
|
||||
|
|
|
@ -13,6 +13,7 @@ import com.annimon.stream.Stream;
|
|||
import org.greenrobot.eventbus.EventBus;
|
||||
import org.greenrobot.eventbus.Subscribe;
|
||||
import org.greenrobot.eventbus.ThreadMode;
|
||||
import org.signal.core.util.Hex;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.libsignal.metadata.certificate.InvalidCertificateException;
|
||||
import org.signal.libsignal.metadata.certificate.SenderCertificate;
|
||||
|
@ -54,7 +55,6 @@ import org.thoughtcrime.securesms.util.Base64;
|
|||
import org.thoughtcrime.securesms.util.BitmapDecodingException;
|
||||
import org.thoughtcrime.securesms.util.BitmapUtil;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.signal.core.util.Hex;
|
||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
|
||||
|
|
|
@ -2,7 +2,13 @@ package org.thoughtcrime.securesms.keyvalue;
|
|||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.google.protobuf.InvalidProtocolBufferException;
|
||||
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.SignalStoreList;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
abstract class SignalStoreValues {
|
||||
|
||||
|
@ -44,6 +50,25 @@ abstract class SignalStoreValues {
|
|||
return store.getBlob(key, defaultValue);
|
||||
}
|
||||
|
||||
<T> List<T> getList(@NonNull String key, @NonNull Serializer<T> serializer) {
|
||||
byte[] blob = getBlob(key, null);
|
||||
if (blob == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
try {
|
||||
SignalStoreList signalStoreList = SignalStoreList.parseFrom(blob);
|
||||
|
||||
return signalStoreList.getContentsList()
|
||||
.stream()
|
||||
.map(serializer::deserialize)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
} catch (InvalidProtocolBufferException e) {
|
||||
throw new IllegalArgumentException(e);
|
||||
}
|
||||
}
|
||||
|
||||
void putBlob(@NonNull String key, byte[] value) {
|
||||
store.beginWrite().putBlob(key, value).apply();
|
||||
}
|
||||
|
@ -68,7 +93,21 @@ abstract class SignalStoreValues {
|
|||
store.beginWrite().putString(key, value).apply();
|
||||
}
|
||||
|
||||
<T> void putList(@NonNull String key, @NonNull List<T> values, @NonNull Serializer<T> serializer) {
|
||||
putBlob(key, SignalStoreList.newBuilder()
|
||||
.addAllContents(values.stream()
|
||||
.map(serializer::serialize)
|
||||
.collect(Collectors.toList()))
|
||||
.build()
|
||||
.toByteArray());
|
||||
}
|
||||
|
||||
void remove(@NonNull String key) {
|
||||
store.beginWrite().remove(key).apply();
|
||||
}
|
||||
|
||||
interface Serializer<T> {
|
||||
@NonNull String serialize(@NonNull T data);
|
||||
T deserialize(@NonNull String data);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
package org.thoughtcrime.securesms.keyvalue
|
||||
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListId
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
|
||||
data class StorySend(
|
||||
val timestamp: Long,
|
||||
val identifier: Identifier
|
||||
) {
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun newSend(recipient: Recipient): StorySend {
|
||||
return if (recipient.isGroup) {
|
||||
StorySend(System.currentTimeMillis(), Identifier.Group(recipient.requireGroupId()))
|
||||
} else {
|
||||
StorySend(System.currentTimeMillis(), Identifier.DistributionList(recipient.requireDistributionListId()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class Identifier {
|
||||
data class Group(val groupId: GroupId) : Identifier() {
|
||||
override fun matches(recipient: Recipient) = recipient.groupId.orElse(null) == groupId
|
||||
}
|
||||
|
||||
data class DistributionList(val distributionListId: DistributionListId) : Identifier() {
|
||||
override fun matches(recipient: Recipient) = recipient.distributionListId.orElse(null) == distributionListId
|
||||
}
|
||||
|
||||
abstract fun matches(recipient: Recipient): Boolean
|
||||
}
|
||||
}
|
|
@ -1,5 +1,9 @@
|
|||
package org.thoughtcrime.securesms.keyvalue
|
||||
|
||||
import org.json.JSONObject
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListId
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
|
||||
internal class StoryValues(store: KeyValueStore) : SignalStoreValues(store) {
|
||||
|
||||
companion object {
|
||||
|
@ -14,6 +18,11 @@ internal class StoryValues(store: KeyValueStore) : SignalStoreValues(store) {
|
|||
* Used to check whether we should display certain dialogs.
|
||||
*/
|
||||
private const val USER_HAS_ADDED_TO_A_STORY = "user.has.added.to.a.story"
|
||||
|
||||
/**
|
||||
* Rolling window of latest two private or group stories a user has sent to.
|
||||
*/
|
||||
private const val LATEST_STORY_SENDS = "latest.story.sends"
|
||||
}
|
||||
|
||||
override fun onFirstEverAppLaunch() = Unit
|
||||
|
@ -25,4 +34,44 @@ internal class StoryValues(store: KeyValueStore) : SignalStoreValues(store) {
|
|||
var lastFontVersionCheck: Long by longValue(LAST_FONT_VERSION_CHECK, 0)
|
||||
|
||||
var userHasBeenNotifiedAboutStories: Boolean by booleanValue(USER_HAS_ADDED_TO_A_STORY, false)
|
||||
|
||||
fun setLatestStorySend(storySend: StorySend) {
|
||||
synchronized(this) {
|
||||
val storySends: List<StorySend> = getList(LATEST_STORY_SENDS, StorySendSerializer)
|
||||
val newStorySends: List<StorySend> = listOf(storySend) + storySends.take(1)
|
||||
putList(LATEST_STORY_SENDS, newStorySends, StorySendSerializer)
|
||||
}
|
||||
}
|
||||
|
||||
fun getLatestActiveStorySendTimestamps(activeCutoffTimestamp: Long): List<StorySend> {
|
||||
val storySends: List<StorySend> = getList(LATEST_STORY_SENDS, StorySendSerializer)
|
||||
return storySends.filter { it.timestamp >= activeCutoffTimestamp }
|
||||
}
|
||||
|
||||
private object StorySendSerializer : Serializer<StorySend> {
|
||||
|
||||
override fun serialize(data: StorySend): String {
|
||||
return JSONObject()
|
||||
.put("timestamp", data.timestamp)
|
||||
.put("groupId", if (data.identifier is StorySend.Identifier.Group) data.identifier.groupId.toString() else null)
|
||||
.put("distributionListId", if (data.identifier is StorySend.Identifier.DistributionList) data.identifier.distributionListId.serialize() else null)
|
||||
.toString()
|
||||
}
|
||||
|
||||
override fun deserialize(data: String): StorySend {
|
||||
val jsonData = JSONObject(data)
|
||||
|
||||
val timestamp = jsonData.getLong("timestamp")
|
||||
|
||||
val identifier = if (jsonData.has("groupId")) {
|
||||
val group = jsonData.getString("groupId")
|
||||
StorySend.Identifier.Group(GroupId.parse(group))
|
||||
} else {
|
||||
val distributionListId = jsonData.getString("distributionListId")
|
||||
StorySend.Identifier.DistributionList(DistributionListId.from(distributionListId))
|
||||
}
|
||||
|
||||
return StorySend(timestamp, identifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,6 +18,8 @@ import org.thoughtcrime.securesms.database.SignalDatabase
|
|||
import org.thoughtcrime.securesms.database.ThreadDatabase
|
||||
import org.thoughtcrime.securesms.database.model.Mention
|
||||
import org.thoughtcrime.securesms.database.model.StoryType
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.keyvalue.StorySend
|
||||
import org.thoughtcrime.securesms.mediasend.CompositeMediaTransform
|
||||
import org.thoughtcrime.securesms.mediasend.ImageEditorModelRenderMediaTransform
|
||||
import org.thoughtcrime.securesms.mediasend.Media
|
||||
|
@ -217,10 +219,14 @@ class MediaSelectionRepository(context: Context) {
|
|||
val recipient = Recipient.resolved(contact.recipientId)
|
||||
val isStory = contact.isStory || recipient.isDistributionList
|
||||
|
||||
if (isStory && recipient.isActiveGroup) {
|
||||
if (isStory && recipient.isActiveGroup && recipient.isGroup) {
|
||||
SignalDatabase.groups.markDisplayAsStory(recipient.requireGroupId())
|
||||
}
|
||||
|
||||
if (isStory && !recipient.isMyStory) {
|
||||
SignalStore.storyValues().setLatestStorySend(StorySend.newSend(recipient))
|
||||
}
|
||||
|
||||
val storyType: StoryType = when {
|
||||
recipient.isDistributionList -> SignalDatabase.distributionLists.getStoryType(recipient.requireDistributionListId())
|
||||
isStory -> StoryType.STORY_WITH_REPLIES
|
||||
|
|
|
@ -9,6 +9,8 @@ import org.thoughtcrime.securesms.database.ThreadDatabase
|
|||
import org.thoughtcrime.securesms.database.model.StoryType
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.StoryTextPost
|
||||
import org.thoughtcrime.securesms.fonts.TextFont
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.keyvalue.StorySend
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview
|
||||
import org.thoughtcrime.securesms.mediasend.v2.UntrustedRecords
|
||||
import org.thoughtcrime.securesms.mediasend.v2.text.TextStoryPostCreationState
|
||||
|
@ -52,10 +54,14 @@ class TextStoryPostSendRepository {
|
|||
val recipient = Recipient.resolved(contact.requireShareContact().recipientId.get())
|
||||
val isStory = contact is ContactSearchKey.RecipientSearchKey.Story || recipient.isDistributionList
|
||||
|
||||
if (isStory && recipient.isActiveGroup) {
|
||||
if (isStory && recipient.isActiveGroup && recipient.isGroup) {
|
||||
SignalDatabase.groups.markDisplayAsStory(recipient.requireGroupId())
|
||||
}
|
||||
|
||||
if (isStory && !recipient.isMyStory) {
|
||||
SignalStore.storyValues().setLatestStorySend(StorySend.newSend(recipient))
|
||||
}
|
||||
|
||||
val storyType: StoryType = when {
|
||||
recipient.isDistributionList -> SignalDatabase.distributionLists.getStoryType(recipient.requireDistributionListId())
|
||||
isStory -> StoryType.STORY_WITH_REPLIES
|
||||
|
|
|
@ -25,6 +25,8 @@ import org.thoughtcrime.securesms.database.model.Mention;
|
|||
import org.thoughtcrime.securesms.database.model.StoryType;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.StoryTextPost;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.keyvalue.StorySend;
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
|
||||
import org.thoughtcrime.securesms.mediasend.Media;
|
||||
import org.thoughtcrime.securesms.mediasend.v2.text.TextStoryBackgroundColors;
|
||||
|
@ -222,10 +224,14 @@ public final class MultiShareSender {
|
|||
storyType = StoryType.STORY_WITH_REPLIES;
|
||||
}
|
||||
|
||||
if (recipient.isActiveGroup()) {
|
||||
if (recipient.isActiveGroup() && recipient.isGroup()) {
|
||||
SignalDatabase.groups().markDisplayAsStory(recipient.requireGroupId());
|
||||
}
|
||||
|
||||
if (!recipient.isMyStory()) {
|
||||
SignalStore.storyValues().setLatestStorySend(StorySend.newSend(recipient));
|
||||
}
|
||||
|
||||
if (multiShareArgs.isTextStory()) {
|
||||
OutgoingMediaMessage outgoingMediaMessage = new OutgoingMediaMessage(recipient,
|
||||
new SlideDeck(),
|
||||
|
|
|
@ -234,4 +234,8 @@ message GiftBadge {
|
|||
|
||||
bytes redemptionToken = 1;
|
||||
RedemptionState redemptionState = 2;
|
||||
}
|
||||
|
||||
message SignalStoreList {
|
||||
repeated string contents = 1;
|
||||
}
|
|
@ -5,12 +5,11 @@ import org.junit.Before
|
|||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.junit.runners.JUnit4
|
||||
import org.mockito.ArgumentMatchers.any
|
||||
import org.mockito.ArgumentMatchers.anyBoolean
|
||||
import org.mockito.ArgumentMatchers.anyInt
|
||||
import org.mockito.ArgumentMatchers.isNull
|
||||
import org.mockito.Mockito.mock
|
||||
import org.mockito.Mockito.`when`
|
||||
import org.mockito.kotlin.any
|
||||
import org.mockito.kotlin.anyOrNull
|
||||
import org.mockito.kotlin.isNull
|
||||
import org.mockito.kotlin.mock
|
||||
import org.mockito.kotlin.whenever
|
||||
import org.thoughtcrime.securesms.MockCursor
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
@ -18,20 +17,21 @@ import org.thoughtcrime.securesms.recipients.RecipientId
|
|||
@RunWith(JUnit4::class)
|
||||
class ContactSearchPagedDataSourceTest {
|
||||
|
||||
private val repository = mock(ContactSearchPagedDataSourceRepository::class.java)
|
||||
private val cursor = mock(MockCursor::class.java)
|
||||
private val repository: ContactSearchPagedDataSourceRepository = mock()
|
||||
private val cursor: MockCursor = mock()
|
||||
private val groupStoryData = ContactSearchData.Story(Recipient.UNKNOWN, 0)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
`when`(repository.getRecipientFromGroupCursor(cursor)).thenReturn(Recipient.UNKNOWN)
|
||||
`when`(repository.getRecipientFromRecipientCursor(cursor)).thenReturn(Recipient.UNKNOWN)
|
||||
`when`(repository.getRecipientFromThreadCursor(cursor)).thenReturn(Recipient.UNKNOWN)
|
||||
`when`(repository.getRecipientFromDistributionListCursor(cursor)).thenReturn(Recipient.UNKNOWN)
|
||||
`when`(repository.getGroupStories()).thenReturn(emptySet())
|
||||
`when`(cursor.moveToPosition(anyInt())).thenCallRealMethod()
|
||||
`when`(cursor.moveToNext()).thenCallRealMethod()
|
||||
`when`(cursor.position).thenCallRealMethod()
|
||||
whenever(repository.getRecipientFromGroupCursor(cursor)).thenReturn(Recipient.UNKNOWN)
|
||||
whenever(repository.getRecipientFromRecipientCursor(cursor)).thenReturn(Recipient.UNKNOWN)
|
||||
whenever(repository.getRecipientFromThreadCursor(cursor)).thenReturn(Recipient.UNKNOWN)
|
||||
whenever(repository.getRecipientFromDistributionListCursor(cursor)).thenReturn(Recipient.UNKNOWN)
|
||||
whenever(repository.getGroupStories()).thenReturn(emptySet())
|
||||
whenever(repository.getLatestStorySends(any())).thenReturn(emptyList())
|
||||
whenever(cursor.moveToPosition(any())).thenCallRealMethod()
|
||||
whenever(cursor.moveToNext()).thenCallRealMethod()
|
||||
whenever(cursor.position).thenCallRealMethod()
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -126,9 +126,9 @@ class ContactSearchPagedDataSourceTest {
|
|||
)
|
||||
}
|
||||
|
||||
`when`(repository.getStories(any())).thenReturn(cursor)
|
||||
`when`(repository.recipientNameContainsQuery(Recipient.UNKNOWN, null)).thenReturn(true)
|
||||
`when`(cursor.count).thenReturn(10)
|
||||
whenever(repository.getStories(anyOrNull())).thenReturn(cursor)
|
||||
whenever(repository.recipientNameContainsQuery(Recipient.UNKNOWN, null)).thenReturn(true)
|
||||
whenever(cursor.count).thenReturn(10)
|
||||
|
||||
return ContactSearchPagedDataSource(configuration, repository)
|
||||
}
|
||||
|
@ -151,9 +151,9 @@ class ContactSearchPagedDataSourceTest {
|
|||
)
|
||||
}
|
||||
|
||||
`when`(repository.getRecents(recents)).thenReturn(cursor)
|
||||
`when`(repository.queryNonGroupContacts(isNull(), anyBoolean())).thenReturn(cursor)
|
||||
`when`(cursor.count).thenReturn(10)
|
||||
whenever(repository.getRecents(recents)).thenReturn(cursor)
|
||||
whenever(repository.queryNonGroupContacts(isNull(), any())).thenReturn(cursor)
|
||||
whenever(cursor.count).thenReturn(10)
|
||||
|
||||
return ContactSearchPagedDataSource(configuration, repository)
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue