From 17111abc72b1a3880ffc6ef0eed6466140e54bdb Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Thu, 21 Apr 2022 17:29:02 -0300 Subject: [PATCH] Add support for smarter story downloads. --- .../securesms/database/MessageDatabase.java | 3 ++ .../securesms/database/MmsDatabase.java | 33 +++++++++++++++++++ .../securesms/database/MmsSmsDatabase.java | 4 +++ .../securesms/database/RecipientDatabase.kt | 4 +++ .../securesms/database/SmsDatabase.java | 10 ++++++ .../messages/MessageContentProcessor.java | 15 +++++---- .../securesms/recipients/Recipient.java | 14 ++++++-- .../thoughtcrime/securesms/stories/Stories.kt | 23 +++++++++++++ .../viewer/page/StoryViewerPageFragment.kt | 4 +++ .../viewer/page/StoryViewerPageRepository.kt | 5 +++ .../viewer/page/StoryViewerPageViewModel.kt | 9 +++-- .../securesms/util/FeatureFlags.java | 11 ++++++- app/src/main/proto/Database.proto | 5 +-- 13 files changed, 125 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java index 1298b0c066..ebcc41370c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java @@ -195,7 +195,10 @@ public abstract class MessageDatabase extends Database implements MmsSmsColumns public abstract @NonNull Cursor getStoryReplies(long parentStoryId); public abstract @Nullable Long getOldestStorySendTimestamp(); public abstract int deleteStoriesOlderThan(long timestamp); + public abstract @NonNull MessageDatabase.Reader getUnreadStories(@NonNull RecipientId recipientId, int limit); + public abstract @NonNull StoryViewState getStoryViewState(@NonNull RecipientId recipientId); + public abstract void updateViewedStories(@NonNull Set syncMessageIds); final @NonNull String getOutgoingTypeClause() { List segments = new ArrayList<>(Types.OUTGOING_MESSAGE_TYPES.length); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java index cec9e3e2be..851c52cb8b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java @@ -590,6 +590,17 @@ public class MmsDatabase extends MessageDatabase { return new Reader(cursor); } + @Override + public @NonNull MessageDatabase.Reader getUnreadStories(@NonNull RecipientId recipientId, int limit) { + final String query = IS_STORY_CLAUSE + + " AND NOT (" + getOutgoingTypeClause() + ") " + + " AND " + RECIPIENT_ID + " = ?" + + " AND " + VIEWED_RECEIPT_COUNT + " = ?"; + final String[] args = SqlUtil.buildArgs(recipientId, 0); + + return new Reader(rawQuery(query, args, false, limit)); + } + @Override public @NonNull StoryViewState getStoryViewState(@NonNull RecipientId recipientId) { if (!Stories.isFeatureEnabled()) { @@ -601,6 +612,28 @@ public class MmsDatabase extends MessageDatabase { return getStoryViewState(threadId); } + /** + * Synchronizes whether we've viewed a recipient's story based on incoming sync messages. + */ + public void updateViewedStories(@NonNull Set syncMessageIds) { + final String timestamps = Util.join(syncMessageIds.stream().map(SyncMessageId::getTimetamp).collect(java.util.stream.Collectors.toList()), ","); + final String[] projection = SqlUtil.buildArgs(RECIPIENT_ID); + final String where = IS_STORY_CLAUSE + " AND " + NORMALIZED_DATE_SENT + " IN (" + timestamps + ") AND NOT (" + getOutgoingTypeClause() + ") AND " + VIEWED_RECEIPT_COUNT + " > 0"; + + try { + getWritableDatabase().beginTransaction(); + try (Cursor cursor = getWritableDatabase().query(TABLE_NAME, projection, where, null, null, null, null)) { + while (cursor != null && cursor.moveToNext()) { + Recipient recipient = Recipient.resolved(RecipientId.from(CursorUtil.requireLong(cursor, RECIPIENT_ID))); + SignalDatabase.recipients().updateLastStoryViewTimestamp(recipient.getId()); + } + } + getWritableDatabase().setTransactionSuccessful(); + } finally { + getWritableDatabase().endTransaction(); + } + } + @VisibleForTesting @NonNull StoryViewState getStoryViewState(long threadId) { final String hasStoryQuery = "SELECT EXISTS(SELECT 1 FROM " + TABLE_NAME + " WHERE " + IS_STORY_CLAUSE + " AND " + THREAD_ID_WHERE + " LIMIT 1)"; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java index b57f3b2108..a37ee48704 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java @@ -32,6 +32,7 @@ import org.signal.libsignal.protocol.util.Pair; import org.thoughtcrime.securesms.database.MessageDatabase.MessageUpdate; import org.thoughtcrime.securesms.database.MessageDatabase.SyncMessageId; import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.database.model.StoryViewState; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.notifications.v2.MessageNotifierV2; import org.thoughtcrime.securesms.recipients.Recipient; @@ -529,6 +530,9 @@ public class MmsSmsDatabase extends Database { return SignalDatabase.mms().incrementReceiptCount(syncMessageId, timestamp, receiptType, true); } + public void updateViewedStories(@NonNull Set syncMessageIds) { + SignalDatabase.mms().updateViewedStories(syncMessageIds); + } public void setTimestampRead(@NonNull Recipient senderRecipient, @NonNull List readMessages, long proposedExpireStarted, @NonNull Map threadToLatestRead) { SQLiteDatabase db = getWritableDatabase(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.kt index a65abee16f..d9353a6b35 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.kt @@ -1964,6 +1964,10 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) : StorageSyncHelper.scheduleSyncForDataChange() } + fun updateLastStoryViewTimestamp(id: RecipientId) { + updateExtras(id) { it.setLastStoryView(System.currentTimeMillis()) } + } + fun clearUsernameIfExists(username: String) { val existingUsername = getByUsername(username) if (existingUsername.isPresent) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java index c0babfe394..2ee1904cf5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -1451,11 +1451,21 @@ public class SmsDatabase extends MessageDatabase { throw new UnsupportedOperationException(); } + @Override + public void updateViewedStories(@NonNull Set syncMessageIds) { + throw new UnsupportedOperationException(); + } + @Override public int deleteStoriesOlderThan(long timestamp) { throw new UnsupportedOperationException(); } + @Override + public @NonNull MessageDatabase.Reader getUnreadStories(@NonNull RecipientId recipientId, int limit) { + throw new UnsupportedOperationException(); + } + @Override public MessageRecord getMessageRecord(long messageId) throws NoSuchMessageException { return getSmsMessage(messageId); diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java index f3a4efaffb..306400a9a9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java @@ -177,12 +177,14 @@ import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.LinkedList; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.UUID; import java.util.concurrent.TimeUnit; @@ -1407,13 +1409,7 @@ public final class MessageContentProcessor { } if (insertResult.isPresent()) { - List allAttachments = SignalDatabase.attachments().getAttachmentsForMessage(insertResult.get().getMessageId()); - List attachments = Stream.of(allAttachments).filterNot(Attachment::isSticker).toList(); - - for (DatabaseAttachment attachment : attachments) { - ApplicationDependencies.getJobManager().add(new AttachmentDownloadJob(insertResult.get().getMessageId(), attachment.getAttachmentId(), false)); - } - + Stories.enqueueNextStoriesForDownload(threadRecipient.getId(), false); ApplicationDependencies.getExpireStoriesManager().scheduleIfNecessary(); } } @@ -2164,6 +2160,11 @@ public final class MessageContentProcessor { Collection unhandled = shouldOnlyProcessStories ? SignalDatabase.mmsSms().incrementViewedStoryReceiptCounts(ids, content.getTimestamp()) : SignalDatabase.mmsSms().incrementViewedReceiptCounts(ids, content.getTimestamp()); + Set handled = new HashSet<>(ids); + handled.removeAll(unhandled); + + SignalDatabase.mmsSms().updateViewedStories(handled); + for (SyncMessageId id : unhandled) { warn(String.valueOf(content.getTimestamp()), "[handleViewedReceipt] Could not find matching message! timestamp: " + id.getTimetamp() + " author: " + senderRecipient.getId()); if (!processingEarlyContent) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java index a2c907125d..73c6de661c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java @@ -12,6 +12,7 @@ import androidx.annotation.WorkerThread; import com.annimon.stream.Stream; +import org.signal.core.util.StringUtil; import org.signal.core.util.logging.Log; import org.signal.libsignal.zkgroup.profiles.ProfileKeyCredential; import org.thoughtcrime.securesms.R; @@ -44,7 +45,6 @@ import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter; import org.thoughtcrime.securesms.profiles.ProfileName; import org.thoughtcrime.securesms.util.AvatarUtil; import org.thoughtcrime.securesms.util.FeatureFlags; -import org.signal.core.util.StringUtil; import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.wallpaper.ChatWallpaper; import org.whispersystems.signalservice.api.push.PNI; @@ -725,6 +725,10 @@ public class Recipient { return extras.map(Extras::hideStory).orElse(false); } + public boolean hasViewedStory() { + return extras.map(Extras::hasViewedStory).orElse(false); + } + public @NonNull GroupId requireGroupId() { GroupId resolved = resolving ? resolve().groupId : groupId; @@ -1218,17 +1222,21 @@ public class Recipient { return recipientExtras.getHideStory(); } + public boolean hasViewedStory() { + return recipientExtras.getLastStoryView() > 0L; + } + @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; final Extras that = (Extras) o; - return manuallyShownAvatar() == that.manuallyShownAvatar() && hideStory() == that.hideStory(); + return manuallyShownAvatar() == that.manuallyShownAvatar() && hideStory() == that.hideStory() && hasViewedStory() == that.hasViewedStory(); } @Override public int hashCode() { - return Objects.hash(manuallyShownAvatar(), hideStory()); + return Objects.hash(manuallyShownAvatar(), hideStory(), hasViewedStory()); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/Stories.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/Stories.kt index 9815e7c67f..b9cacf9224 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/Stories.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/Stories.kt @@ -5,9 +5,12 @@ import androidx.fragment.app.FragmentManager import io.reactivex.rxjava3.core.Completable import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.contacts.HeaderAction +import org.thoughtcrime.securesms.database.AttachmentDatabase import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.model.DistributionListId +import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.jobs.AttachmentDownloadJob import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.mediasend.v2.stories.ChooseStoryTypeBottomSheet import org.thoughtcrime.securesms.mms.OutgoingSecureMediaMessage @@ -72,4 +75,24 @@ object Stories { SignalDatabase.recipients.markNeedsSync(storyRecipientId) StorageSyncHelper.scheduleSyncForDataChange() } + + @JvmStatic + @WorkerThread + fun enqueueNextStoriesForDownload(recipientId: RecipientId, ignoreAutoDownloadConstraints: Boolean = false) { + val recipient = Recipient.resolved(recipientId) + if (!recipient.isSelf && (recipient.shouldHideStory() || !recipient.hasViewedStory())) { + return + } + + val unreadStoriesReader = SignalDatabase.mms.getUnreadStories(recipientId, FeatureFlags.storiesAutoDownloadMaximum()) + while (unreadStoriesReader.next != null) { + val record = unreadStoriesReader.current as MmsMessageRecord + SignalDatabase.attachments.getAttachmentsForMessage(record.id).filterNot { it.isSticker }.forEach { + if (it.transferState == AttachmentDatabase.TRANSFER_PROGRESS_PENDING) { + val job = AttachmentDownloadJob(record.id, it.attachmentId, ignoreAutoDownloadConstraints) + ApplicationDependencies.getJobManager().add(job) + } + } + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt index 39770c2dd0..7e5c0117af 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt @@ -547,6 +547,10 @@ class StoryViewerPageFragment : AttachmentDatabase.TRANSFER_PROGRESS_DONE -> { storySlate.moveToState(StorySlateView.State.HIDDEN, post.id) viewModel.setIsDisplayingSlate(false) + + if (post.content.transferState == AttachmentDatabase.TRANSFER_PROGRESS_DONE) { + viewModel.markViewed(post) + } } AttachmentDatabase.TRANSFER_PROGRESS_PENDING -> { storySlate.moveToState(StorySlateView.State.LOADING, post.id) diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageRepository.kt index 73df1a5a3d..8b97ff0ef5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageRepository.kt @@ -22,6 +22,7 @@ import org.thoughtcrime.securesms.jobs.MultiDeviceViewedUpdateJob import org.thoughtcrime.securesms.jobs.SendViewedReceiptJob import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.stories.Stories import org.thoughtcrime.securesms.util.Base64 /** @@ -173,6 +174,10 @@ open class StoryViewerPageRepository(context: Context) { ) ) MultiDeviceViewedUpdateJob.enqueue(listOf(markedMessageInfo.syncMessageId)) + + val recipientId = storyPost.group?.id ?: storyPost.sender.id + SignalDatabase.recipients.updateLastStoryViewTimestamp(recipientId) + Stories.enqueueNextStoriesForDownload(recipientId, true) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageViewModel.kt index f61bf98f97..21d5329a59 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageViewModel.kt @@ -10,6 +10,7 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.kotlin.plusAssign import io.reactivex.rxjava3.subjects.PublishSubject import io.reactivex.rxjava3.subjects.Subject +import org.thoughtcrime.securesms.database.AttachmentDatabase import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.util.livedata.Store import org.thoughtcrime.securesms.util.rx.RxStore @@ -76,11 +77,15 @@ class StoryViewerPageViewModel( return repository.hideStory(recipientId) } + fun markViewed(storyPost: StoryPost) { + repository.markViewed(storyPost) + } + fun setSelectedPostIndex(index: Int) { val selectedPost = getPostAt(index) - if (selectedPost != null) { - repository.markViewed(selectedPost) + if (selectedPost != null && selectedPost.content.transferState != AttachmentDatabase.TRANSFER_PROGRESS_DONE) { + repository.forceDownload(selectedPost) } store.update { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java index 83315ce06e..40230810bb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -97,6 +97,7 @@ public final class FeatureFlags { private static final String PAYMENTS_COUNTRY_BLOCKLIST = "android.payments.blocklist"; private static final String PNP_CDS = "android.pnp.cds"; private static final String USE_FCM_FOREGROUND_SERVICE = "android.useFcmForegroundService"; + private static final String STORIES_AUTO_DOWNLOAD_MAXIMUM = "android.stories.autoDownloadMaximum"; /** * We will only store remote values for flags in this set. If you want a flag to be controllable @@ -145,7 +146,8 @@ public final class FeatureFlags { USE_HARDWARE_AEC_IF_OLD, USE_AEC3, PAYMENTS_COUNTRY_BLOCKLIST, - USE_FCM_FOREGROUND_SERVICE + USE_FCM_FOREGROUND_SERVICE, + STORIES_AUTO_DOWNLOAD_MAXIMUM ); @VisibleForTesting @@ -512,6 +514,13 @@ public final class FeatureFlags { return getBoolean(USE_FCM_FOREGROUND_SERVICE, false); } + /** + * Prefetch count for stories from a given user. + */ + public static int storiesAutoDownloadMaximum() { + return getInteger(STORIES_AUTO_DOWNLOAD_MAXIMUM, 2); + } + /** Only for rendering debug info. */ public static synchronized @NonNull Map getMemoryValues() { return new TreeMap<>(REMOTE_VALUES); diff --git a/app/src/main/proto/Database.proto b/app/src/main/proto/Database.proto index ba2ec213f4..c049860310 100644 --- a/app/src/main/proto/Database.proto +++ b/app/src/main/proto/Database.proto @@ -178,8 +178,9 @@ message ChatColor { } message RecipientExtras { - bool manuallyShownAvatar = 1; - bool hideStory = 2; + bool manuallyShownAvatar = 1; + bool hideStory = 2; + int64 lastStoryView = 3; } message CustomAvatar {