From 88a66b49ff3655297bc11e16243840acbc571e07 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Tue, 14 Jun 2022 12:50:53 -0300 Subject: [PATCH] Apply new story list ordering rules. Co-authored-by: Cody Henthorne --- .../contacts/paged/ActiveContactCount.kt | 6 + .../paged/ContactSearchConfiguration.kt | 2 +- .../paged/ContactSearchPagedDataSource.kt | 126 ++++++++++++++---- .../ContactSearchPagedDataSourceRepository.kt | 7 + .../forward/MultiselectForwardFragment.kt | 3 +- .../database/model/DistributionListId.java | 9 ++ .../securesms/jobs/PushGroupSendJob.java | 48 +++---- .../securesms/jobs/PushSendJob.java | 2 +- .../securesms/keyvalue/SignalStoreValues.java | 39 ++++++ .../securesms/keyvalue/StorySend.kt | 33 +++++ .../securesms/keyvalue/StoryValues.kt | 49 +++++++ .../mediasend/v2/MediaSelectionRepository.kt | 8 +- .../text/send/TextStoryPostSendRepository.kt | 8 +- .../securesms/sharing/MultiShareSender.java | 8 +- app/src/main/proto/Database.proto | 4 + .../paged/ContactSearchPagedDataSourceTest.kt | 44 +++--- 16 files changed, 319 insertions(+), 77 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ActiveContactCount.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/keyvalue/StorySend.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ActiveContactCount.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ActiveContactCount.kt new file mode 100644 index 000000000..b4c7ba0e1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ActiveContactCount.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchConfiguration.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchConfiguration.kt index 01016734a..8655778cb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchConfiguration.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchConfiguration.kt @@ -83,7 +83,7 @@ class ContactSearchConfiguration private constructor( */ data class ExpandConfig( val isExpanded: Boolean, - val maxCountWhenNotExpanded: Int = 2 + val maxCountWhenNotExpanded: (ActiveContactCount) -> Int = { 2 } ) /** diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt index 6e693067f..683c98150 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt @@ -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 { + companion object { + private val ACTIVE_STORY_CUTOFF_DURATION = TimeUnit.DAYS.toMillis(1) + } + + private val latestStorySends: List = 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 = when (section) { + is ContactSearchConfiguration.Section.Stories -> getFilteredGroupStories(section, query) + else -> emptyList() + } - val extras: List = 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 { @@ -133,7 +142,7 @@ class ContactSearchPagedDataSource( ): List { val results = mutableListOf() - 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, + 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, - 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, + cursorMapper: (Cursor) -> ContactSearchData, + activeContactCount: Int, + val storyComparator: StoryComparator + ) : ResultsCollection(section, cursor, extraData, cursorMapper, activeContactCount) { + private val aggregateStoryData: List by lazy { + if (section !is ContactSearchConfiguration.Section.Stories) { + error("Aggregate data creation is only necessary for stories.") + } + + val cursorContacts: List = (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) : Comparator { + 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 } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSourceRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSourceRepository.kt index fdf9734af..392c150e9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSourceRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSourceRepository.kt @@ -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 { + return SignalStore.storyValues() + .getLatestActiveStorySendTimestamps(System.currentTimeMillis() - activeStoryCutoffDuration) + } + open fun querySignalContacts(query: String?, includeSelf: Boolean): Cursor? { return contactRepository.querySignalContacts(query ?: "", includeSelf) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragment.kt index ad736a0c8..545e7ab19 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragment.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/DistributionListId.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/DistributionListId.java index c01e902fb..0515812d9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/DistributionListId.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/DistributionListId.java @@ -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; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java index b5f98ff35..049c2a74b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java @@ -85,11 +85,11 @@ public final class PushGroupSendJob extends PushSendJob { public PushGroupSendJob(long messageId, @NonNull RecipientId destination, @NonNull Set 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 mentions = getMentionsFor(message.getMentions()); List attachments = Stream.of(message.getAttachments()).filterNot(Attachment::isSticker).toList(); List 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 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 networkFailures = Stream.of(results).filter(SendMessageResult::isNetworkFailure).map(result -> new NetworkFailure(accessList.requireIdByAddress(result.getAddress()))).toList(); - List identityMismatches = Stream.of(results).filter(result -> result.getIdentityFailure() != null).map(result -> new IdentityKeyMismatch(accessList.requireIdByAddress(result.getAddress()), result.getIdentityFailure().getIdentityKey())).toList(); + List 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 successes = Stream.of(results).filter(result -> result.getSuccess() != null).toList(); List> 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 destinations = SignalDatabase.groupReceipts().getGroupReceiptInfo(messageId); - List possible; + List 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 eligible = RecipientUtil.getEligibleForSending(possible); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java index d145b429a..7176baef9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java @@ -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; diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStoreValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStoreValues.java index 955081295..5c1cf90f0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStoreValues.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStoreValues.java @@ -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); } + List getList(@NonNull String key, @NonNull Serializer 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(); } + void putList(@NonNull String key, @NonNull List values, @NonNull Serializer 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 { + @NonNull String serialize(@NonNull T data); + T deserialize(@NonNull String data); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StorySend.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StorySend.kt new file mode 100644 index 000000000..003bdae9e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StorySend.kt @@ -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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StoryValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StoryValues.kt index fbbb15acf..6c5b4aec1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StoryValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StoryValues.kt @@ -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 = getList(LATEST_STORY_SENDS, StorySendSerializer) + val newStorySends: List = listOf(storySend) + storySends.take(1) + putList(LATEST_STORY_SENDS, newStorySends, StorySendSerializer) + } + } + + fun getLatestActiveStorySendTimestamps(activeCutoffTimestamp: Long): List { + val storySends: List = getList(LATEST_STORY_SENDS, StorySendSerializer) + return storySends.filter { it.timestamp >= activeCutoffTimestamp } + } + + private object StorySendSerializer : Serializer { + + 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) + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt index de5495717..4dbc9b471 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendRepository.kt index f7aac69c7..acbf5940f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendRepository.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareSender.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareSender.java index 121232518..a20c235e3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareSender.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareSender.java @@ -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(), diff --git a/app/src/main/proto/Database.proto b/app/src/main/proto/Database.proto index 54b758f15..352f9df82 100644 --- a/app/src/main/proto/Database.proto +++ b/app/src/main/proto/Database.proto @@ -234,4 +234,8 @@ message GiftBadge { bytes redemptionToken = 1; RedemptionState redemptionState = 2; +} + +message SignalStoreList { + repeated string contents = 1; } \ No newline at end of file diff --git a/app/src/test/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSourceTest.kt b/app/src/test/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSourceTest.kt index dd058c73a..bb4073886 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSourceTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSourceTest.kt @@ -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) }