From ab44d608d278929d26774330bdf4d50655e434df Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Wed, 28 Apr 2021 16:21:34 -0300 Subject: [PATCH] Add support for sending and syncing viewed receipts behind a feature flag. --- .../components/ConversationItemFooter.java | 10 +- .../voice/VoiceNotePlaybackService.java | 54 +++++- .../conversation/ConversationItem.java | 7 +- .../securesms/database/MessageDatabase.java | 1 + .../securesms/database/MmsDatabase.java | 71 +++++--- .../securesms/database/SmsDatabase.java | 5 + .../securesms/jobs/JobManagerFactories.java | 1 + .../jobs/MultiDeviceViewedUpdateJob.java | 163 ++++++++++++++++++ .../securesms/jobs/SendViewedReceiptJob.java | 2 +- .../messages/MessageContentProcessor.java | 22 +++ .../revealable/ViewOnceMessageRepository.java | 19 +- .../api/SignalServiceMessageSender.java | 24 +++ .../api/messages/SignalServiceContent.java | 16 ++ .../multidevice/SignalServiceSyncMessage.java | 45 ++++- .../messages/multidevice/ViewedMessage.java | 22 +++ .../src/main/proto/SignalService.proto | 7 + 16 files changed, 425 insertions(+), 44 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceViewedUpdateJob.java create mode 100644 libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/ViewedMessage.java diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java index 1c1c9a7191..a5cdefa05c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java @@ -99,7 +99,7 @@ public class ConversationItemFooter extends LinearLayout { presentTimer(messageRecord); presentInsecureIndicator(messageRecord); presentDeliveryStatus(messageRecord); - hideAudioDurationViews(); + presentAudioDuration(messageRecord); } public void setAudioDuration(long totalDurationMillis, long currentPostionMillis) { @@ -259,6 +259,12 @@ public class ConversationItemFooter extends LinearLayout { moveAudioViewsForIncoming(); } showAudioDurationViews(); + + if (messageRecord.getViewedReceiptCount() > 0) { + revealDot.setProgress(1f); + } else { + revealDot.setProgress(0f); + } } else { hideAudioDurationViews(); } @@ -295,7 +301,7 @@ public class ConversationItemFooter extends LinearLayout { private void showAudioDurationViews() { audioSpace.setVisibility(View.VISIBLE); - audioDuration.setVisibility(View.VISIBLE); + audioDuration.setVisibility(View.GONE); if (FeatureFlags.viewedReceipts()) { revealDot.setVisibility(View.VISIBLE); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackService.java b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackService.java index ef469bb516..c60aeb3ab4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackService.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/voice/VoiceNotePlaybackService.java @@ -33,7 +33,17 @@ import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; import com.google.android.exoplayer2.ui.PlayerNotificationManager; +import org.signal.core.util.concurrent.SignalExecutors; import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MessageDatabase; +import org.thoughtcrime.securesms.database.MmsSmsDatabase; +import org.thoughtcrime.securesms.database.model.MmsMessageRecord; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobs.MultiDeviceViewedUpdateJob; +import org.thoughtcrime.securesms.jobs.SendViewedReceiptJob; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.video.exo.AttachmentMediaSourceFactory; import java.util.Collections; @@ -48,10 +58,10 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat { private static final String EMPTY_ROOT_ID = "empty-root-id"; private static final int LOAD_MORE_THRESHOLD = 2; - private static final long SUPPORTED_ACTIONS = PlaybackStateCompat.ACTION_PLAY | - PlaybackStateCompat.ACTION_PAUSE | - PlaybackStateCompat.ACTION_SEEK_TO | - PlaybackStateCompat.ACTION_STOP | + private static final long SUPPORTED_ACTIONS = PlaybackStateCompat.ACTION_PLAY | + PlaybackStateCompat.ACTION_PAUSE | + PlaybackStateCompat.ACTION_SEEK_TO | + PlaybackStateCompat.ACTION_STOP | PlaybackStateCompat.ACTION_PLAY_PAUSE; private MediaSessionCompat mediaSession; @@ -152,6 +162,7 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat { becomingNoisyReceiver.unregister(); voiceNoteProximityManager.onPlayerEnded(); } else { + sendViewedReceiptForCurrentWindowIndex(); becomingNoisyReceiver.register(); voiceNoteProximityManager.onPlayerReady(); } @@ -172,11 +183,12 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat { if (reason == Player.DISCONTINUITY_REASON_PERIOD_TRANSITION) { MediaDescriptionCompat mediaDescriptionCompat = queueDataAdapter.getMediaDescription(currentWindowIndex); + sendViewedReceiptForCurrentWindowIndex(); Log.d(TAG, "onPositionDiscontinuity: current window uri: " + mediaDescriptionCompat.getMediaUri()); } - boolean isWithinThreshold = currentWindowIndex < LOAD_MORE_THRESHOLD || - currentWindowIndex + LOAD_MORE_THRESHOLD >= queueDataAdapter.size(); + boolean isWithinThreshold = currentWindowIndex < LOAD_MORE_THRESHOLD || + currentWindowIndex + LOAD_MORE_THRESHOLD >= queueDataAdapter.size(); if (isWithinThreshold && currentWindowIndex % 2 == 0) { voiceNotePlaybackPreparer.loadMoreVoiceNotes(); @@ -190,6 +202,36 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat { } } + private void sendViewedReceiptForCurrentWindowIndex() { + if (player.getPlaybackState() == Player.STATE_READY && + player.getPlayWhenReady() && + player.getCurrentWindowIndex() != C.INDEX_UNSET && + FeatureFlags.sendViewedReceipts()) { + + final MediaDescriptionCompat descriptionCompat = queueDataAdapter.getMediaDescription(player.getCurrentWindowIndex()); + + if (!descriptionCompat.getMediaUri().getScheme().equals("content")) { + return; + } + + SignalExecutors.BOUNDED.execute(() -> { + Bundle extras = descriptionCompat.getExtras(); + long messageId = extras.getLong(VoiceNoteMediaDescriptionCompatFactory.EXTRA_MESSAGE_ID); + RecipientId recipientId = RecipientId.from(extras.getString(VoiceNoteMediaDescriptionCompatFactory.EXTRA_THREAD_RECIPIENT_ID)); + MessageDatabase messageDatabase = DatabaseFactory.getMmsDatabase(this); + + MessageDatabase.MarkedMessageInfo markedMessageInfo = messageDatabase.setIncomingMessageViewed(messageId); + + if (markedMessageInfo != null) { + ApplicationDependencies.getJobManager().add(new SendViewedReceiptJob(markedMessageInfo.getThreadId(), + recipientId, + markedMessageInfo.getSyncMessageId().getTimetamp())); + MultiDeviceViewedUpdateJob.enqueue(Collections.singletonList(markedMessageInfo.getSyncMessageId())); + } + }); + } + } + private class VoiceNoteNotificationManagerListener implements PlayerNotificationManager.NotificationListener { @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java index 8d6c2d53ec..733f0a79cf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -117,6 +117,7 @@ import org.thoughtcrime.securesms.revealable.ViewOnceUtil; import org.thoughtcrime.securesms.stickers.StickerUrl; import org.thoughtcrime.securesms.util.DateUtils; import org.thoughtcrime.securesms.util.DynamicTheme; +import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.InterceptableLongClickCopyLinkSpan; import org.thoughtcrime.securesms.util.LongClickMovementMethod; import org.thoughtcrime.securesms.util.SearchUtil; @@ -1164,7 +1165,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo boolean differentTimestamps = next.isPresent() && !DateUtils.isSameExtendedRelativeTimestamp(context, locale, next.get().getTimestamp(), current.getTimestamp()); - if (current.getExpiresIn() > 0 || !current.isSecure() || current.isPending() || current.isPendingInsecureSmsFallback() || + if (forceFooter(messageRecord) || current.getExpiresIn() > 0 || !current.isSecure() || current.isPending() || current.isPendingInsecureSmsFallback() || current.isFailed() || differentTimestamps || isEndOfMessageCluster(current, next, isGroupThread)) { ConversationItemFooter activeFooter = getActiveFooter(current); @@ -1189,6 +1190,10 @@ public final class ConversationItem extends RelativeLayout implements BindableCo } } + private boolean forceFooter(@NonNull MessageRecord messageRecord) { + return FeatureFlags.viewedReceipts() && hasAudio(messageRecord) && messageRecord.getViewedReceiptCount() == 0; + } + private ConversationItemFooter getActiveFooter(@NonNull MessageRecord messageRecord) { if (hasNoBubble(messageRecord)) { return stickerFooter; 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 6a6a843988..40bb9d747c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java @@ -128,6 +128,7 @@ public abstract class MessageDatabase extends Database implements MmsSmsColumns public abstract Pair updateBundleMessageBody(long messageId, String body); public abstract @NonNull List getViewedIncomingMessages(long threadId); public abstract @Nullable MarkedMessageInfo setIncomingMessageViewed(long messageId); + public abstract @NonNull List setIncomingMessagesViewed(@NonNull List messageIds); public abstract void addFailures(long messageId, List failure); public abstract void removeFailure(long messageId, NetworkFailure failure); 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 ec0d60b290..ae58cf6000 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java @@ -412,39 +412,56 @@ public class MmsDatabase extends MessageDatabase { @Override public @Nullable MarkedMessageInfo setIncomingMessageViewed(long messageId) { - SQLiteDatabase database = databaseHelper.getWritableDatabase(); - String[] columns = new String[]{ID, RECIPIENT_ID, DATE_SENT, MESSAGE_BOX, THREAD_ID}; - String where = ID_WHERE + " AND " + VIEWED_RECEIPT_COUNT + " = 0"; - String[] args = SqlUtil.buildArgs(messageId); + List results = setIncomingMessagesViewed(Collections.singletonList(messageId)); + + if (results.isEmpty()) { + return null; + } else { + return results.get(0); + } + } + + @Override + public @NonNull List setIncomingMessagesViewed(@NonNull List messageIds) { + if (messageIds.isEmpty()) { + return Collections.emptyList(); + } + + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + String[] columns = new String[]{ID, RECIPIENT_ID, DATE_SENT, MESSAGE_BOX, THREAD_ID}; + String where = ID + " IN (" + Util.join(messageIds, ",") + ") AND " + VIEWED_RECEIPT_COUNT + " = 0"; + List results = new LinkedList<>(); database.beginTransaction(); - try (Cursor cursor = database.query(TABLE_NAME, columns, where, args, null, null, null)) { - if (cursor == null || !cursor.moveToFirst()) { - return null; - } - - long type = CursorUtil.requireLong(cursor, MESSAGE_BOX); - if (Types.isSecureType(type) && Types.isInboxType(type)) { - long threadId = CursorUtil.requireLong(cursor, THREAD_ID); - RecipientId recipientId = RecipientId.from(CursorUtil.requireLong(cursor, RECIPIENT_ID)); - long dateSent = CursorUtil.requireLong(cursor, DATE_SENT); - SyncMessageId syncMessageId = new SyncMessageId(recipientId, dateSent); - - MarkedMessageInfo result = new MarkedMessageInfo(threadId, syncMessageId, null); - - ContentValues contentValues = new ContentValues(); - contentValues.put(VIEWED_RECEIPT_COUNT, 1); - - database.update(TABLE_NAME, contentValues, where, args); - database.setTransactionSuccessful(); - - return result; - } else { - return null; + try (Cursor cursor = database.query(TABLE_NAME, columns, where, null, null, null, null)) { + while (cursor != null && cursor.moveToNext()) { + long type = CursorUtil.requireLong(cursor, MESSAGE_BOX); + if (Types.isSecureType(type) && Types.isInboxType(type)) { + long threadId = CursorUtil.requireLong(cursor, THREAD_ID); + RecipientId recipientId = RecipientId.from(CursorUtil.requireLong(cursor, RECIPIENT_ID)); + long dateSent = CursorUtil.requireLong(cursor, DATE_SENT); + SyncMessageId syncMessageId = new SyncMessageId(recipientId, dateSent); + + results.add(new MarkedMessageInfo(threadId, syncMessageId, null)); + + ContentValues contentValues = new ContentValues(); + contentValues.put(VIEWED_RECEIPT_COUNT, 1); + + database.update(TABLE_NAME, contentValues, ID_WHERE, SqlUtil.buildArgs(CursorUtil.requireLong(cursor, ID))); + } } + database.setTransactionSuccessful(); } finally { database.endTransaction(); } + + Set threadsUpdated = Stream.of(results) + .map(MarkedMessageInfo::getThreadId) + .collect(Collectors.toSet()); + + notifyConversationListeners(threadsUpdated); + + return results; } @Override 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 4366fb07d2..0a478335fa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -635,6 +635,11 @@ public class SmsDatabase extends MessageDatabase { return null; } + @Override + public @NonNull List setIncomingMessagesViewed(@NonNull List messageIds) { + return Collections.emptyList(); + } + private Pair updateMessageBodyAndType(long messageId, String body, long maskOff, long maskOn) { SQLiteDatabase db = databaseHelper.getWritableDatabase(); db.execSQL("UPDATE " + TABLE_NAME + " SET " + BODY + " = ?, " + diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index fdde286d5f..3a2a528477 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -104,6 +104,7 @@ public final class JobManagerFactories { put(MultiDeviceStorageSyncRequestJob.KEY, new MultiDeviceStorageSyncRequestJob.Factory()); put(MultiDeviceVerifiedUpdateJob.KEY, new MultiDeviceVerifiedUpdateJob.Factory()); put(MultiDeviceViewOnceOpenJob.KEY, new MultiDeviceViewOnceOpenJob.Factory()); + put(MultiDeviceViewedUpdateJob.KEY, new MultiDeviceViewedUpdateJob.Factory()); put(ProfileKeySendJob.KEY, new ProfileKeySendJob.Factory()); put(PushDecryptMessageJob.KEY, new PushDecryptMessageJob.Factory()); put(PushDecryptDrainedJob.KEY, new PushDecryptDrainedJob.Factory()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceViewedUpdateJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceViewedUpdateJob.java new file mode 100644 index 0000000000..77e767508c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceViewedUpdateJob.java @@ -0,0 +1,163 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; + +import com.annimon.stream.Stream; +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; +import org.thoughtcrime.securesms.database.MessageDatabase.SyncMessageId; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.JobManager; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.util.JsonUtils; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.signalservice.api.SignalServiceMessageSender; +import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; +import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage; +import org.whispersystems.signalservice.api.messages.multidevice.ViewedMessage; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; + +import java.io.IOException; +import java.io.Serializable; +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +public class MultiDeviceViewedUpdateJob extends BaseJob { + + public static final String KEY = "MultiDeviceViewedUpdateJob"; + + private static final String TAG = Log.tag(MultiDeviceViewedUpdateJob.class); + + private static final String KEY_MESSAGE_IDS = "message_ids"; + + private List messageIds; + + private MultiDeviceViewedUpdateJob(List messageIds) { + this(new Parameters.Builder() + .addConstraint(NetworkConstraint.KEY) + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .setMaxAttempts(Parameters.UNLIMITED) + .build(), + SendReadReceiptJob.ensureSize(messageIds, SendReadReceiptJob.MAX_TIMESTAMPS)); + } + + private MultiDeviceViewedUpdateJob(@NonNull Parameters parameters, @NonNull List messageIds) { + super(parameters); + + this.messageIds = new LinkedList<>(); + + for (SyncMessageId messageId : messageIds) { + this.messageIds.add(new SerializableSyncMessageId(messageId.getRecipientId().serialize(), messageId.getTimetamp())); + } + } + + /** + * Enqueues all the necessary jobs for read receipts, ensuring that they're all within the + * maximum size. + */ + public static void enqueue(@NonNull List messageIds) { + JobManager jobManager = ApplicationDependencies.getJobManager(); + List> messageIdChunks = Util.chunk(messageIds, SendReadReceiptJob.MAX_TIMESTAMPS); + + if (messageIdChunks.size() > 1) { + Log.w(TAG, "Large receipt count! Had to break into multiple chunks. Total count: " + messageIds.size()); + } + + for (List chunk : messageIdChunks) { + jobManager.add(new MultiDeviceViewedUpdateJob(chunk)); + } + } + + @Override + public @NonNull Data serialize() { + String[] ids = new String[messageIds.size()]; + + for (int i = 0; i < ids.length; i++) { + try { + ids[i] = JsonUtils.toJson(messageIds.get(i)); + } catch (IOException e) { + throw new AssertionError(e); + } + } + + return new Data.Builder().putStringArray(KEY_MESSAGE_IDS, ids).build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onRun() throws IOException, UntrustedIdentityException { + if (!TextSecurePreferences.isMultiDevice(context)) { + Log.i(TAG, "Not multi device..."); + return; + } + + List viewedMessages = new LinkedList<>(); + + for (SerializableSyncMessageId messageId : messageIds) { + Recipient recipient = Recipient.resolved(RecipientId.from(messageId.recipientId)); + if (!recipient.isGroup()) { + viewedMessages.add(new ViewedMessage(RecipientUtil.toSignalServiceAddress(context, recipient), messageId.timestamp)); + } + } + + SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); + messageSender.sendMessage(SignalServiceSyncMessage.forViewed(viewedMessages), UnidentifiedAccessUtil.getAccessForSync(context)); + } + + @Override + public boolean onShouldRetry(@NonNull Exception exception) { + return exception instanceof PushNetworkException; + } + + @Override + public void onFailure() { + + } + + private static class SerializableSyncMessageId implements Serializable { + + private static final long serialVersionUID = 1L; + + @JsonProperty + private final String recipientId; + + @JsonProperty + private final long timestamp; + + private SerializableSyncMessageId(@JsonProperty("recipientId") String recipientId, @JsonProperty("timestamp") long timestamp) { + this.recipientId = recipientId; + this.timestamp = timestamp; + } + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull MultiDeviceViewedUpdateJob create(@NonNull Parameters parameters, @NonNull Data data) { + List ids = Stream.of(data.getStringArray(KEY_MESSAGE_IDS)) + .map(id -> { + try { + return JsonUtils.fromJson(id, SerializableSyncMessageId.class); + } catch (IOException e) { + throw new AssertionError(e); + } + }) + .map(id -> new SyncMessageId(RecipientId.from(id.recipientId), id.timestamp)) + .toList(); + + return new MultiDeviceViewedUpdateJob(parameters, ids); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/SendViewedReceiptJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/SendViewedReceiptJob.java index da2b7e88fb..4a6d92b983 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/SendViewedReceiptJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/SendViewedReceiptJob.java @@ -57,7 +57,7 @@ public class SendViewedReceiptJob extends BaseJob { .build(), threadId, recipientId, - syncTimestamps, + SendReadReceiptJob.ensureSize(syncTimestamps, SendReadReceiptJob.MAX_TIMESTAMPS), System.currentTimeMillis()); } 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 1928f08e91..358dc9e97f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java @@ -101,6 +101,7 @@ import org.thoughtcrime.securesms.util.GroupUtil; import org.thoughtcrime.securesms.util.Hex; import org.thoughtcrime.securesms.util.IdentityUtil; import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.MessageRecordUtil; import org.thoughtcrime.securesms.util.RemoteDeleteUtil; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.Util; @@ -133,6 +134,7 @@ import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSy import org.whispersystems.signalservice.api.messages.multidevice.StickerPackOperationMessage; import org.whispersystems.signalservice.api.messages.multidevice.VerifiedMessage; import org.whispersystems.signalservice.api.messages.multidevice.ViewOnceOpenMessage; +import org.whispersystems.signalservice.api.messages.multidevice.ViewedMessage; import org.whispersystems.signalservice.api.messages.shared.SharedContact; import org.whispersystems.signalservice.api.payments.Money; import org.whispersystems.signalservice.api.push.SignalServiceAddress; @@ -147,6 +149,7 @@ import java.util.LinkedList; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Objects; import java.util.UUID; /** @@ -268,6 +271,7 @@ public final class MessageContentProcessor { if (syncMessage.getSent().isPresent()) handleSynchronizeSentMessage(content, syncMessage.getSent().get()); else if (syncMessage.getRequest().isPresent()) handleSynchronizeRequestMessage(syncMessage.getRequest().get()); else if (syncMessage.getRead().isPresent()) handleSynchronizeReadMessage(syncMessage.getRead().get(), content.getTimestamp()); + else if (syncMessage.getViewed().isPresent()) handleSynchronizeViewedMessage(syncMessage.getViewed().get(), content.getTimestamp()); else if (syncMessage.getViewOnceOpen().isPresent()) handleSynchronizeViewOnceOpenMessage(syncMessage.getViewOnceOpen().get(), content.getTimestamp()); else if (syncMessage.getVerified().isPresent()) handleSynchronizeVerifiedMessage(syncMessage.getVerified().get()); else if (syncMessage.getStickerPackOperations().isPresent()) handleSynchronizeStickerPackOperation(syncMessage.getStickerPackOperations().get()); @@ -1019,6 +1023,24 @@ public final class MessageContentProcessor { messageNotifier.updateNotification(context); } + private void handleSynchronizeViewedMessage(@NonNull List viewedMessages, long envelopeTimestamp) { + List toMarkViewed = Stream.of(viewedMessages) + .map(message -> { + RecipientId author = Recipient.externalPush(context, message.getSender()).getId(); + return DatabaseFactory.getMmsSmsDatabase(context).getMessageFor(message.getTimestamp(), author); + }) + .filter(message -> message != null && message.isMms()) + .map(MessageRecord::getId) + .toList(); + + DatabaseFactory.getMmsDatabase(context).setIncomingMessagesViewed(toMarkViewed); + + MessageNotifier messageNotifier = ApplicationDependencies.getMessageNotifier(); + messageNotifier.setLastDesktopActivityTimestamp(envelopeTimestamp); + messageNotifier.cancelDelayedNotifications(); + messageNotifier.updateNotification(context); + } + private void handleSynchronizeViewOnceOpenMessage(@NonNull ViewOnceOpenMessage openMessage, long envelopeTimestamp) { log(String.valueOf(envelopeTimestamp), "Handling a view-once open for message: " + openMessage.getTimestamp()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/revealable/ViewOnceMessageRepository.java b/app/src/main/java/org/thoughtcrime/securesms/revealable/ViewOnceMessageRepository.java index acce4b69ac..c33ebc7f88 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/revealable/ViewOnceMessageRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/revealable/ViewOnceMessageRepository.java @@ -11,9 +11,13 @@ import org.thoughtcrime.securesms.database.MessageDatabase; import org.thoughtcrime.securesms.database.MmsDatabase; import org.thoughtcrime.securesms.database.model.MmsMessageRecord; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobs.MultiDeviceViewedUpdateJob; import org.thoughtcrime.securesms.jobs.SendViewedReceiptJob; +import org.thoughtcrime.securesms.util.FeatureFlags; import org.whispersystems.libsignal.util.guava.Optional; +import java.util.Collections; + class ViewOnceMessageRepository { private static final String TAG = Log.tag(ViewOnceMessageRepository.class); @@ -28,12 +32,17 @@ class ViewOnceMessageRepository { SignalExecutors.BOUNDED.execute(() -> { try (MmsDatabase.Reader reader = MmsDatabase.readerFor(mmsDatabase.getMessageCursor(messageId))) { MmsMessageRecord record = (MmsMessageRecord) reader.getNext(); - MessageDatabase.MarkedMessageInfo info = mmsDatabase.setIncomingMessageViewed(record.getId()); - if (info != null) { - ApplicationDependencies.getJobManager().add(new SendViewedReceiptJob(record.getThreadId(), - info.getSyncMessageId().getRecipientId(), - info.getSyncMessageId().getTimetamp())); + + if (FeatureFlags.sendViewedReceipts()) { + MessageDatabase.MarkedMessageInfo info = mmsDatabase.setIncomingMessageViewed(record.getId()); + if (info != null) { + ApplicationDependencies.getJobManager().add(new SendViewedReceiptJob(record.getThreadId(), + info.getSyncMessageId().getRecipientId(), + info.getSyncMessageId().getTimetamp())); + MultiDeviceViewedUpdateJob.enqueue(Collections.singletonList(info.getSyncMessageId())); + } } + callback.onComplete(Optional.fromNullable(record)); } }); diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java index d8f5670fcd..7d11e4fec0 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java @@ -49,6 +49,7 @@ import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSy import org.whispersystems.signalservice.api.messages.multidevice.StickerPackOperationMessage; import org.whispersystems.signalservice.api.messages.multidevice.VerifiedMessage; import org.whispersystems.signalservice.api.messages.multidevice.ViewOnceOpenMessage; +import org.whispersystems.signalservice.api.messages.multidevice.ViewedMessage; import org.whispersystems.signalservice.api.messages.shared.SharedContact; import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException; @@ -354,6 +355,8 @@ public class SignalServiceMessageSender { content = createMultiDeviceGroupsContent(message.getGroups().get().asStream()); } else if (message.getRead().isPresent()) { content = createMultiDeviceReadContent(message.getRead().get()); + } else if (message.getViewed().isPresent()) { + content = createMultiDeviceViewedContent(message.getViewed().get()); } else if (message.getViewOnceOpen().isPresent()) { content = createMultiDeviceViewOnceOpenContent(message.getViewOnceOpen().get()); } else if (message.getBlockedList().isPresent()) { @@ -964,6 +967,27 @@ public class SignalServiceMessageSender { return container.setSyncMessage(builder).build().toByteArray(); } + private byte[] createMultiDeviceViewedContent(List readMessages) { + Content.Builder container = Content.newBuilder(); + SyncMessage.Builder builder = createSyncMessageBuilder(); + + for (ViewedMessage readMessage : readMessages) { + SyncMessage.Viewed.Builder viewedBuilder = SyncMessage.Viewed.newBuilder().setTimestamp(readMessage.getTimestamp()); + + if (readMessage.getSender().getUuid().isPresent()) { + viewedBuilder.setSenderUuid(readMessage.getSender().getUuid().get().toString()); + } + + if (readMessage.getSender().getNumber().isPresent()) { + viewedBuilder.setSenderE164(readMessage.getSender().getNumber().get()); + } + + builder.addViewed(viewedBuilder.build()); + } + + return container.setSyncMessage(builder).build().toByteArray(); + } + private byte[] createMultiDeviceViewOnceOpenContent(ViewOnceOpenMessage readMessage) { Content.Builder container = Content.newBuilder(); SyncMessage.Builder builder = createSyncMessageBuilder(); diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceContent.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceContent.java index fee05a9ae4..a11a1b09f2 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceContent.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceContent.java @@ -36,6 +36,7 @@ import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSy import org.whispersystems.signalservice.api.messages.multidevice.StickerPackOperationMessage; import org.whispersystems.signalservice.api.messages.multidevice.VerifiedMessage; import org.whispersystems.signalservice.api.messages.multidevice.ViewOnceOpenMessage; +import org.whispersystems.signalservice.api.messages.multidevice.ViewedMessage; import org.whispersystems.signalservice.api.messages.shared.SharedContact; import org.whispersystems.signalservice.api.payments.Money; import org.whispersystems.signalservice.api.push.SignalServiceAddress; @@ -465,6 +466,21 @@ public final class SignalServiceContent { return SignalServiceSyncMessage.forRead(readMessages); } + if (content.getViewedList().size() > 0) { + List viewedMessages = new LinkedList<>(); + + for (SignalServiceProtos.SyncMessage.Viewed viewed : content.getViewedList()) { + if (SignalServiceAddress.isValidAddress(viewed.getSenderUuid(), viewed.getSenderE164())) { + SignalServiceAddress address = new SignalServiceAddress(UuidUtil.parseOrNull(viewed.getSenderUuid()), viewed.getSenderE164()); + viewedMessages.add(new ViewedMessage(address, viewed.getTimestamp())); + } else { + Log.w(TAG, "Encountered an invalid ReadMessage! Ignoring."); + } + } + + return SignalServiceSyncMessage.forViewed(viewedMessages); + } + if (content.hasViewOnceOpen()) { if (SignalServiceAddress.isValidAddress(content.getViewOnceOpen().getSenderUuid(), content.getViewOnceOpen().getSenderE164())) { SignalServiceAddress address = new SignalServiceAddress(UuidUtil.parseOrNull(content.getViewOnceOpen().getSenderUuid()), content.getViewOnceOpen().getSenderE164()); diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/SignalServiceSyncMessage.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/SignalServiceSyncMessage.java index 18b245adc6..1c96e18074 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/SignalServiceSyncMessage.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/SignalServiceSyncMessage.java @@ -28,6 +28,7 @@ public class SignalServiceSyncMessage { private final Optional keys; private final Optional messageRequestResponse; private final Optional outgoingPaymentMessage; + private final Optional> views; private SignalServiceSyncMessage(Optional sent, Optional contacts, @@ -42,7 +43,8 @@ public class SignalServiceSyncMessage { Optional fetchType, Optional keys, Optional messageRequestResponse, - Optional outgoingPaymentMessage) + Optional outgoingPaymentMessage, + Optional> views) { this.sent = sent; this.contacts = contacts; @@ -58,6 +60,7 @@ public class SignalServiceSyncMessage { this.keys = keys; this.messageRequestResponse = messageRequestResponse; this.outgoingPaymentMessage = outgoingPaymentMessage; + this.views = views; } public static SignalServiceSyncMessage forSentTranscript(SentTranscriptMessage sent) { @@ -74,6 +77,7 @@ public class SignalServiceSyncMessage { Optional.absent(), Optional.absent(), Optional.absent(), + Optional.absent(), Optional.absent()); } @@ -91,6 +95,7 @@ public class SignalServiceSyncMessage { Optional.absent(), Optional.absent(), Optional.absent(), + Optional.absent(), Optional.absent()); } @@ -108,6 +113,7 @@ public class SignalServiceSyncMessage { Optional.absent(), Optional.absent(), Optional.absent(), + Optional.absent(), Optional.absent()); } @@ -125,6 +131,7 @@ public class SignalServiceSyncMessage { Optional.absent(), Optional.absent(), Optional.absent(), + Optional.absent(), Optional.absent()); } @@ -142,9 +149,28 @@ public class SignalServiceSyncMessage { Optional.absent(), Optional.absent(), Optional.absent(), + Optional.absent(), Optional.absent()); } + public static SignalServiceSyncMessage forViewed(List views) { + return new SignalServiceSyncMessage(Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.absent(), + Optional.of(views)); + } + public static SignalServiceSyncMessage forViewOnceOpen(ViewOnceOpenMessage timerRead) { return new SignalServiceSyncMessage(Optional.absent(), Optional.absent(), @@ -159,6 +185,7 @@ public class SignalServiceSyncMessage { Optional.absent(), Optional.absent(), Optional.absent(), + Optional.absent(), Optional.absent()); } @@ -179,6 +206,7 @@ public class SignalServiceSyncMessage { Optional.absent(), Optional.absent(), Optional.absent(), + Optional.absent(), Optional.absent()); } @@ -196,6 +224,7 @@ public class SignalServiceSyncMessage { Optional.absent(), Optional.absent(), Optional.absent(), + Optional.absent(), Optional.absent()); } @@ -213,6 +242,7 @@ public class SignalServiceSyncMessage { Optional.absent(), Optional.absent(), Optional.absent(), + Optional.absent(), Optional.absent()); } @@ -230,6 +260,7 @@ public class SignalServiceSyncMessage { Optional.absent(), Optional.absent(), Optional.absent(), + Optional.absent(), Optional.absent()); } @@ -247,6 +278,7 @@ public class SignalServiceSyncMessage { Optional.absent(), Optional.absent(), Optional.absent(), + Optional.absent(), Optional.absent()); } @@ -264,6 +296,7 @@ public class SignalServiceSyncMessage { Optional.of(fetchType), Optional.absent(), Optional.absent(), + Optional.absent(), Optional.absent()); } @@ -281,6 +314,7 @@ public class SignalServiceSyncMessage { Optional.absent(), Optional.of(keys), Optional.absent(), + Optional.absent(), Optional.absent()); } @@ -298,6 +332,7 @@ public class SignalServiceSyncMessage { Optional.absent(), Optional.absent(), Optional.of(messageRequestResponse), + Optional.absent(), Optional.absent()); } @@ -315,7 +350,8 @@ public class SignalServiceSyncMessage { Optional.absent(), Optional.absent(), Optional.absent(), - Optional.of(outgoingPaymentMessage)); + Optional.of(outgoingPaymentMessage), + Optional.absent()); } public static SignalServiceSyncMessage empty() { @@ -332,6 +368,7 @@ public class SignalServiceSyncMessage { Optional.absent(), Optional.absent(), Optional.absent(), + Optional.absent(), Optional.absent()); } @@ -391,6 +428,10 @@ public class SignalServiceSyncMessage { return outgoingPaymentMessage; } + public Optional> getViewed() { + return views; + } + public enum FetchType { LOCAL_PROFILE, STORAGE_MANIFEST diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/ViewedMessage.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/ViewedMessage.java new file mode 100644 index 0000000000..2ac989ce64 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/ViewedMessage.java @@ -0,0 +1,22 @@ +package org.whispersystems.signalservice.api.messages.multidevice; + +import org.whispersystems.signalservice.api.push.SignalServiceAddress; + +public class ViewedMessage { + + private final SignalServiceAddress sender; + private final long timestamp; + + public ViewedMessage(SignalServiceAddress sender, long timestamp) { + this.sender = sender; + this.timestamp = timestamp; + } + + public long getTimestamp() { + return timestamp; + } + + public SignalServiceAddress getSender() { + return sender; + } +} diff --git a/libsignal/service/src/main/proto/SignalService.proto b/libsignal/service/src/main/proto/SignalService.proto index b4903be734..e24c081d66 100644 --- a/libsignal/service/src/main/proto/SignalService.proto +++ b/libsignal/service/src/main/proto/SignalService.proto @@ -403,6 +403,12 @@ message SyncMessage { optional uint64 timestamp = 2; } + message Viewed { + optional string senderE164 = 1; + optional string senderUuid = 3; + optional uint64 timestamp = 2; + } + message Configuration { optional bool readReceipts = 1; optional bool unidentifiedDeliveryIndicators = 2; @@ -495,6 +501,7 @@ message SyncMessage { optional Keys keys = 13; optional MessageRequestResponse messageRequestResponse = 14; optional OutgoingPayment outgoingPayment = 15; + repeated Viewed viewed = 16; } message AttachmentPointer {