From 1faf196f82414e0f6d8ca133a5cefc34a3988bcb Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Fri, 21 Feb 2020 13:52:27 -0500 Subject: [PATCH] Implement additional message request improvements. --- .../securesms/ApplicationContext.java | 10 +- .../components/TypingStatusRepository.java | 1 - .../conversation/ConversationActivity.java | 125 ++++++++++-- .../conversation/ConversationFragment.java | 26 +-- .../ConversationListItem.java | 1 - .../securesms/database/GroupDatabase.java | 1 - .../securesms/database/MmsDatabase.java | 29 ++- .../securesms/database/MmsSmsDatabase.java | 19 +- .../securesms/database/RecipientDatabase.java | 1 + .../securesms/database/SmsDatabase.java | 30 ++- .../securesms/database/ThreadDatabase.java | 4 +- .../database/loaders/ConversationLoader.java | 8 +- .../groups/GroupMessageProcessor.java | 12 +- .../securesms/jobs/JobManagerFactories.java | 2 + .../securesms/jobs/LeaveGroupJob.java | 179 ++++++++++++++++++ .../MultiDeviceMessageRequestResponseJob.java | 164 ++++++++++++++++ .../securesms/jobs/PushGroupSendJob.java | 16 +- .../securesms/jobs/PushMediaSendJob.java | 9 +- .../securesms/jobs/PushProcessMessageJob.java | 62 +++++- .../securesms/jobs/PushTextSendJob.java | 9 +- .../jobs/RemoteConfigRefreshJob.java | 1 - .../securesms/jobs/SendReadReceiptJob.java | 2 +- .../keyvalue/RemoteConfigValues.java | 42 ++++ .../securesms/keyvalue/SignalStore.java | 29 ++- .../mediasend/MediaSendViewModel.java | 1 - .../MessageRequestRepository.java | 137 ++++++++++---- .../MessageRequestViewModel.java | 115 +++++++---- .../MessageRequestsBottomView.java | 47 ++++- .../notifications/MessageNotifier.java | 2 +- .../reactions/ReactionRecipientsAdapter.java | 1 - .../securesms/recipients/Recipient.java | 26 ++- .../securesms/recipients/RecipientUtil.java | 167 +++++++++++----- .../securesms/util/FeatureFlags.java | 129 ++++++++++--- .../securesms/util/GroupUtil.java | 9 +- .../res/layout/message_request_bottom_bar.xml | 63 +++++- app/src/main/res/values/strings.xml | 53 ++++-- .../recipients/RecipientUtilTest.java | 37 ++-- .../securesms/util/FeatureFlagsTest.java | 51 ++++- .../api/SignalServiceMessageSender.java | 45 +++++ .../api/messages/SignalServiceContent.java | 39 ++++ .../MessageRequestResponseMessage.java | 45 +++++ .../multidevice/SignalServiceSyncMessage.java | 93 ++++++--- .../src/main/proto/SignalService.proto | 42 ++-- 43 files changed, 1523 insertions(+), 361 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/jobs/LeaveGroupJob.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceMessageRequestResponseJob.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/keyvalue/RemoteConfigValues.java create mode 100644 libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/MessageRequestResponseMessage.java diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index 16692546ff..0c63b6782e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -42,16 +42,11 @@ import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider; import org.thoughtcrime.securesms.gcm.FcmJobService; -import org.thoughtcrime.securesms.insights.InsightsOptOut; -import org.thoughtcrime.securesms.jobmanager.JobManager; import org.thoughtcrime.securesms.jobs.CreateSignedPreKeyJob; import org.thoughtcrime.securesms.jobs.FcmRefreshJob; import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob; import org.thoughtcrime.securesms.jobs.PushNotificationReceiveJob; import org.thoughtcrime.securesms.jobs.RefreshPreKeysJob; -import org.thoughtcrime.securesms.jobs.StickerPackDownloadJob; -import org.thoughtcrime.securesms.keyvalue.SignalStore; -import org.thoughtcrime.securesms.megaphone.MegaphoneRepository; import org.thoughtcrime.securesms.logging.AndroidLogger; import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger; import org.thoughtcrime.securesms.logging.Log; @@ -63,6 +58,7 @@ import org.thoughtcrime.securesms.notifications.MessageNotifier; import org.thoughtcrime.securesms.notifications.NotificationChannels; import org.thoughtcrime.securesms.providers.BlobProvider; import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess; +import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.ringrtc.RingRtcLogger; import org.thoughtcrime.securesms.service.DirectoryRefreshListener; import org.thoughtcrime.securesms.service.ExpiringMessageManager; @@ -73,10 +69,8 @@ import org.thoughtcrime.securesms.revealable.ViewOnceMessageManager; import org.thoughtcrime.securesms.service.RotateSenderCertificateListener; import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener; import org.thoughtcrime.securesms.service.UpdateApkRefreshListener; -import org.thoughtcrime.securesms.stickers.BlessedPacks; import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.TextSecurePreferences; -import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper; import org.webrtc.voiceengine.WebRtcAudioManager; @@ -153,7 +147,7 @@ public class ApplicationContext extends MultiDexApplication implements DefaultLi public void onStart(@NonNull LifecycleOwner owner) { isAppVisible = true; Log.i(TAG, "App is now visible."); - FeatureFlags.refresh(); + FeatureFlags.refreshIfNecessary(); ApplicationDependencies.getRecipientCache().warmUp(); executePendingContactSync(); KeyCachingService.onAppForegrounded(this); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/TypingStatusRepository.java b/app/src/main/java/org/thoughtcrime/securesms/components/TypingStatusRepository.java index 737be9da7d..6c3ece3aa7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/TypingStatusRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/TypingStatusRepository.java @@ -11,7 +11,6 @@ import com.annimon.stream.Stream; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.Util; import java.util.ArrayList; diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java index 2fdad3e8b0..1953cf361c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java @@ -70,7 +70,6 @@ import androidx.appcompat.widget.Toolbar; import androidx.core.content.pm.ShortcutInfoCompat; import androidx.core.content.pm.ShortcutManagerCompat; import androidx.core.graphics.drawable.IconCompat; -import androidx.core.text.HtmlCompat; import androidx.lifecycle.ViewModelProviders; import androidx.recyclerview.widget.RecyclerView; @@ -152,6 +151,7 @@ import org.thoughtcrime.securesms.giph.ui.GiphyActivity; import org.thoughtcrime.securesms.insights.InsightsLauncher; import org.thoughtcrime.securesms.invites.InviteReminderModel; import org.thoughtcrime.securesms.invites.InviteReminderRepository; +import org.thoughtcrime.securesms.jobs.LeaveGroupJob; import org.thoughtcrime.securesms.jobs.RetrieveProfileJob; import org.thoughtcrime.securesms.jobs.ServiceOutageDetectionJob; import org.thoughtcrime.securesms.linkpreview.LinkPreview; @@ -216,7 +216,6 @@ import org.thoughtcrime.securesms.util.DynamicLanguage; import org.thoughtcrime.securesms.util.DynamicTheme; import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.GroupUtil; -import org.thoughtcrime.securesms.util.HtmlUtil; import org.thoughtcrime.securesms.util.IdentityUtil; import org.thoughtcrime.securesms.util.MediaUtil; import org.thoughtcrime.securesms.util.MessageUtil; @@ -1120,7 +1119,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity Optional leaveMessage = GroupUtil.createGroupLeaveMessage(this, groupRecipient); if (threadId != -1 && leaveMessage.isPresent()) { - MessageSender.send(this, leaveMessage.get(), threadId, false, null); + ApplicationDependencies.getJobManager().add(LeaveGroupJob.create(groupRecipient)); GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(this); String groupId = groupRecipient.requireGroupId(); @@ -2043,7 +2042,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity } private void setBlockedUserState(Recipient recipient, boolean isSecureText, boolean isDefaultSms) { - if (recipient.isBlocked()) { + if (recipient.isBlocked() && !FeatureFlags.messageRequests()) { unblockButton.setVisibility(View.VISIBLE); composePanel.setVisibility(View.GONE); makeDefaultSmsButton.setVisibility(View.GONE); @@ -2067,7 +2066,11 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity } private void setGroupShareProfileReminder(@NonNull Recipient recipient) { - if (!shouldDisplayMessageRequestUi && recipient.isPushGroup() && !recipient.isProfileSharing()) { + if (FeatureFlags.messageRequests()) { + return; + } + + if (recipient.isPushGroup() && !recipient.isProfileSharing()) { groupShareProfileView.get().setRecipient(recipient); groupShareProfileView.get().setVisibility(View.VISIBLE); } else if (groupShareProfileView.resolved()) { @@ -2777,14 +2780,14 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity @Override public void onMessageRequest(@NonNull MessageRequestViewModel viewModel) { - - messageRequestBottomView.setAcceptOnClickListener(v -> viewModel.accept()); - messageRequestBottomView.setDeleteOnClickListener(v -> viewModel.delete()); - messageRequestBottomView.setBlockOnClickListener(v -> viewModel.block()); + messageRequestBottomView.setAcceptOnClickListener(v -> viewModel.onAccept()); + messageRequestBottomView.setDeleteOnClickListener(v -> onMessageRequestDeleteClicked(viewModel)); + messageRequestBottomView.setBlockOnClickListener(v -> onMessageRequestBlockClicked(viewModel)); + messageRequestBottomView.setUnblockOnClickListener(v -> onMessageRequestUnblockClicked(viewModel)); viewModel.getRecipient().observe(this, this::presentMessageRequestBottomViewTo); - viewModel.getShouldDisplayMessageRequest().observe(this, this::handleShouldDisplayMessageRequest); - viewModel.getMesasgeRequestStatus().observe(this, status -> { + viewModel.getMessageRequestDisplayState().observe(this, this::presentMessageRequestDisplayState); + viewModel.getMessageRequestStatus().observe(this, status -> { switch (status) { case ACCEPTED: messageRequestBottomView.setVisibility(View.GONE); @@ -2909,14 +2912,104 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity updateLinkPreviewState(); } - private void handleShouldDisplayMessageRequest(boolean shouldDisplayMessageRequest) { - shouldDisplayMessageRequestUi = shouldDisplayMessageRequest; - setGroupShareProfileReminder(recipient.get()); + private void onMessageRequestDeleteClicked(@NonNull MessageRequestViewModel requestModel) { + Recipient recipient = requestModel.getRecipient().getValue(); + if (recipient == null) { + Log.w(TAG, "[onMessageRequestDeleteClicked] No recipient!"); + return; + } + AlertDialog.Builder builder = new AlertDialog.Builder(this) + .setNeutralButton(R.string.ConversationActivity_cancel, (d, w) -> d.dismiss()); + + if (recipient.isGroup() && recipient.isBlocked()) { + builder.setTitle(R.string.ConversationActivity_delete_conversation); + builder.setMessage(R.string.ConversationActivity_this_conversation_will_be_deleted_from_all_of_your_devices); + builder.setPositiveButton(R.string.ConversationActivity_delete, (d, w) -> requestModel.onDelete()); + } else if (recipient.isGroup()) { + builder.setTitle(R.string.ConversationActivity_delete_and_leave_group); + builder.setMessage(R.string.ConversationActivity_you_will_leave_this_group_and_it_will_be_deleted_from_all_of_your_devices); + builder.setNegativeButton(R.string.ConversationActivity_delete_and_leave, (d, w) -> requestModel.onDelete()); + } else { + builder.setTitle(R.string.ConversationActivity_delete_conversation); + builder.setMessage(R.string.ConversationActivity_this_conversation_will_be_deleted_from_all_of_your_devices); + builder.setNegativeButton(R.string.ConversationActivity_delete, (d, w) -> requestModel.onDelete()); + } + + builder.show(); + } + + private void onMessageRequestBlockClicked(@NonNull MessageRequestViewModel requestModel) { + Recipient recipient = requestModel.getRecipient().getValue(); + if (recipient == null) { + Log.w(TAG, "[onMessageRequestBlockClicked] No recipient!"); + return; + } + + AlertDialog.Builder builder = new AlertDialog.Builder(this) + .setNeutralButton(R.string.ConversationActivity_cancel, (d, w) -> d.dismiss()) + .setPositiveButton(R.string.ConversationActivity_block_and_delete, (d, w) -> requestModel.onBlockAndDelete()) + .setNegativeButton(R.string.ConversationActivity_block, (d, w) -> requestModel.onBlock()); + + if (recipient.isGroup()) { + builder.setTitle(getString(R.string.ConversationActivity_block_and_leave_s, recipient.getDisplayName(this))); + builder.setMessage(R.string.ConversationActivity_you_will_leave_this_group_and_no_longer_receive_messages_or_updates); + } else { + builder.setTitle(getString(R.string.ConversationActivity_block_s, recipient.getDisplayName(this))); + builder.setMessage(R.string.ConversationActivity_blocked_people_will_not_be_able_to_call_you_or_send_you_messages); + } + + builder.show(); + } + + private void onMessageRequestUnblockClicked(@NonNull MessageRequestViewModel requestModel) { + Recipient recipient = requestModel.getRecipient().getValue(); + if (recipient == null) { + Log.w(TAG, "[onMessageRequestUnblockClicked] No recipient!"); + return; + } + + AlertDialog.Builder builder = new AlertDialog.Builder(this) + .setTitle(getString(R.string.ConversationActivity_unblock_s, recipient.getDisplayName(this))) + .setNeutralButton(R.string.ConversationActivity_cancel, (d, w) -> d.dismiss()) + .setNegativeButton(R.string.ConversationActivity_unblock, (d, w) -> requestModel.onUnblock()); + + if (recipient.isGroup()) { + builder.setMessage(R.string.ConversationActivity_group_members_will_be_able_to_add_you_to_this_group_again); + } else { + builder.setMessage(R.string.ConversationActivity_you_will_be_able_to_message_and_call_each_other); + } + + builder.show(); + } + + private void presentMessageRequestDisplayState(@NonNull MessageRequestViewModel.DisplayState displayState) { if (getIntent().hasExtra(TEXT_EXTRA) || getIntent().hasExtra(MEDIA_EXTRA) || getIntent().hasExtra(STICKER_EXTRA) || (isPushGroupConversation() && !isActiveGroup())) { + Log.d(TAG, "[presentMessageRequestDisplayState] Have extra, so ignoring provided state."); messageRequestBottomView.setVisibility(View.GONE); } else { - messageRequestBottomView.setVisibility(shouldDisplayMessageRequest ? View.VISIBLE : View.GONE); + Log.d(TAG, "[presentMessageRequestDisplayState] " + displayState); + switch (displayState) { + case DISPLAY_MESSAGE_REQUEST: + messageRequestBottomView.setVisibility(View.VISIBLE); + if (groupShareProfileView.resolved()) { + groupShareProfileView.get().setVisibility(View.GONE); + } + break; + case DISPLAY_LEGACY: + if (recipient.get().isGroup()) { + groupShareProfileView.get().setRecipient(recipient.get()); + groupShareProfileView.get().setVisibility(View.VISIBLE); + } + messageRequestBottomView.setVisibility(View.GONE); + break; + case DISPLAY_NONE: + messageRequestBottomView.setVisibility(View.GONE); + if (groupShareProfileView.resolved()) { + groupShareProfileView.get().setVisibility(View.GONE); + } + break; + } } invalidateOptionsMenu(); @@ -3019,6 +3112,6 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity private void presentMessageRequestBottomViewTo(@Nullable Recipient recipient) { if (recipient == null) return; - messageRequestBottomView.setQuestionText(HtmlCompat.fromHtml(getString(R.string.MessageRequestBottomView_do_you_want_to_let, HtmlUtil.bold(recipient.getDisplayName(this))), 0)); + messageRequestBottomView.setRecipient(recipient); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java index 95670e6c0c..333281f698 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -65,6 +65,7 @@ import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.MessageDetailsActivity; import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.sharing.ShareActivity; import org.thoughtcrime.securesms.attachments.Attachment; import org.thoughtcrime.securesms.components.ConversationTypingView; import org.thoughtcrime.securesms.components.TooltipPopup; @@ -155,7 +156,6 @@ public class ConversationFragment extends Fragment private int activeOffset; private boolean firstLoad; private boolean isReacting; - private boolean shouldDisplayMessageRequest; private ActionMode actionMode; private Locale locale; private RecyclerView list; @@ -206,7 +206,7 @@ public class ConversationFragment extends Fragment new ConversationItemSwipeCallback( messageRecord -> actionMode == null && - canReplyToMessage(isActionMessage(messageRecord), messageRecord, shouldDisplayMessageRequest), + canReplyToMessage(isActionMessage(messageRecord), messageRecord, messageRequestViewModel.shouldShowMessageRequest()), this::handleReplyMessage ).attachToRecyclerView(list); @@ -334,12 +334,6 @@ public class ConversationFragment extends Fragment presentMessageRequestProfileView(requireContext(), recipientInfo, conversationBanner); presentMessageRequestProfileView(requireContext(), recipientInfo, emptyConversationBanner); }); - - messageRequestViewModel.getShouldDisplayMessageRequest().observe(getViewLifecycleOwner(), this::handleShouldDisplayMessageRequest); - } - - private void handleShouldDisplayMessageRequest(boolean shouldDisplayMessageRequest) { - this.shouldDisplayMessageRequest = shouldDisplayMessageRequest; } private static void presentMessageRequestProfileView(@NonNull Context context, @NonNull MessageRequestViewModel.RecipientInfo recipientInfo, @Nullable ConversationBannerView conversationBanner) { @@ -558,7 +552,7 @@ public class ConversationFragment extends Fragment menu.findItem(R.id.menu_context_forward).setVisible(!actionMessage && !sharedContact && !viewOnce); menu.findItem(R.id.menu_context_details).setVisible(!actionMessage); - menu.findItem(R.id.menu_context_reply).setVisible(canReplyToMessage(actionMessage, messageRecord, shouldDisplayMessageRequest)); + menu.findItem(R.id.menu_context_reply).setVisible(canReplyToMessage(actionMessage, messageRecord, messageRequestViewModel.shouldShowMessageRequest())); } menu.findItem(R.id.menu_context_copy).setVisible(!actionMessage && hasText); } @@ -839,8 +833,8 @@ public class ConversationFragment extends Fragment @Override public void onLoadFinished(@NonNull Loader cursorLoader, Cursor cursor) { - int count = cursor.getCount(); - ConversationLoader loader = (ConversationLoader)cursorLoader; + int count = cursor.getCount(); + ConversationLoader loader = (ConversationLoader) cursorLoader; ConversationAdapter adapter = getListAdapter(); if (adapter == null) { @@ -859,7 +853,7 @@ public class ConversationFragment extends Fragment setLastSeen(loader.getLastSeen()); } - if (FeatureFlags.messageRequests()) { + if (FeatureFlags.messageRequests() && !loader.hasPreMessageRequestMessages()) { clearHeaderIfNotTyping(adapter); } else { if (!loader.hasSent() && !recipient.get().isSystemContact() && !recipient.get().isGroup() && recipient.get().getRegistered() == RecipientDatabase.RegisteredState.REGISTERED) { @@ -1139,10 +1133,10 @@ public class ConversationFragment extends Fragment if (actionMode != null) return; - if (messageRecord.isSecure() && - !messageRecord.isUpdate() && - !recipient.get().isBlocked() && - !shouldDisplayMessageRequest && + if (messageRecord.isSecure() && + !messageRecord.isUpdate() && + !recipient.get().isBlocked() && + !messageRequestViewModel.shouldShowMessageRequest() && ((ConversationAdapter) list.getAdapter()).getSelectedItems().isEmpty()) { isReacting = true; diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java index bcbca1a918..5f1ac7cb0a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java @@ -49,7 +49,6 @@ import org.thoughtcrime.securesms.recipients.RecipientForeverObserver; import org.thoughtcrime.securesms.conversationlist.model.MessageResult; import org.thoughtcrime.securesms.util.DateUtils; import org.thoughtcrime.securesms.util.SearchUtil; -import org.thoughtcrime.securesms.util.SpanUtil; import org.thoughtcrime.securesms.util.ThemeUtil; import org.thoughtcrime.securesms.util.ViewUtil; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java index f81d89227c..d6a7ba7076 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java @@ -15,7 +15,6 @@ import com.annimon.stream.Stream; import net.sqlcipher.database.SQLiteDatabase; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; -import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.util.BitmapUtil; 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 ae76b8e124..020c1f3b43 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java @@ -282,16 +282,31 @@ public class MmsDatabase extends MessagingDatabase { public int getMessageCountForThread(long threadId) { SQLiteDatabase db = databaseHelper.getReadableDatabase(); - Cursor cursor = null; - try { - cursor = db.query(TABLE_NAME, new String[] {"COUNT(*)"}, THREAD_ID + " = ?", new String[] {threadId+""}, null, null, null); + String[] cols = new String[] {"COUNT(*)"}; + String query = THREAD_ID + " = ?"; + String[] args = new String[]{String.valueOf(threadId)}; - if (cursor != null && cursor.moveToFirst()) + try (Cursor cursor = db.query(TABLE_NAME, cols, query, args, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { return cursor.getInt(0); - } finally { - if (cursor != null) - cursor.close(); + } + } + + return 0; + } + + public int getMessageCountForThread(long threadId, long beforeTime) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + + String[] cols = new String[] {"COUNT(*)"}; + String query = THREAD_ID + " = ? AND " + DATE_RECEIVED + " < ?"; + String[] args = new String[]{String.valueOf(threadId), String.valueOf(beforeTime)}; + + try (Cursor cursor = db.query(TABLE_NAME, cols, query, args, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + return cursor.getInt(0); + } } return 0; 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 95e8e0df89..e98cb7af07 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java @@ -22,8 +22,6 @@ import android.database.Cursor; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import com.annimon.stream.Stream; - import net.sqlcipher.database.SQLiteDatabase; import net.sqlcipher.database.SQLiteQueryBuilder; @@ -35,7 +33,6 @@ import org.thoughtcrime.securesms.recipients.RecipientId; import org.whispersystems.libsignal.util.Pair; import java.util.HashSet; -import java.util.List; import java.util.Set; public class MmsSmsDatabase extends Database { @@ -88,24 +85,27 @@ public class MmsSmsDatabase extends Database { super(context, databaseHelper); } - public @Nullable RecipientId getRecipientIdForLatestAdd(long threadId) { + /** + * @return The user that added you to the group, otherwise null. + */ + public @Nullable RecipientId getGroupAddedBy(long threadId) { long lastQuitChecked = System.currentTimeMillis(); Pair pair; do { - pair = getRecipientIdForLatestAdd(threadId, lastQuitChecked); + pair = getGroupAddedBy(threadId, lastQuitChecked); if (pair.first() != null) { return pair.first(); } else { lastQuitChecked = pair.second(); } - } while (pair.second() != -1L); + } while (pair.second() != -1); return null; } - private @NonNull Pair getRecipientIdForLatestAdd(long threadId, long lastQuitChecked) { + private @NonNull Pair getGroupAddedBy(long threadId, long lastQuitChecked) { MmsDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(context); SmsDatabase smsDatabase = DatabaseFactory.getSmsDatabase(context); long latestQuit = mmsDatabase.getLatestGroupQuitTimestamp(threadId, lastQuitChecked); @@ -225,6 +225,11 @@ public class MmsSmsDatabase extends Database { return count; } + public int getConversationCount(long threadId, long beforeTime) { + return DatabaseFactory.getSmsDatabase(context).getMessageCountForThread(threadId, beforeTime) + + DatabaseFactory.getMmsDatabase(context).getMessageCountForThread(threadId, beforeTime); + } + public int getInsecureSentCount(long threadId) { int count = DatabaseFactory.getSmsDatabase(context).getInsecureMessagesSentForThread(threadId); count += DatabaseFactory.getMmsDatabase(context).getInsecureMessagesSentForThread(threadId); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java index cf0cb78e10..de4558eece 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java @@ -1222,6 +1222,7 @@ public class RecipientDatabase extends Database { ContentValues setBlocked = new ContentValues(); setBlocked.put(BLOCKED, 1); + setBlocked.put(PROFILE_SHARING, 0); for (String e164 : blockedE164) { db.update(TABLE_NAME, setBlocked, PHONE + " = ?", new String[] { e164 }); 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 55b3d26bdd..98e75e74f8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -216,17 +216,31 @@ public class SmsDatabase extends MessagingDatabase { public int getMessageCountForThread(long threadId) { SQLiteDatabase db = databaseHelper.getReadableDatabase(); - Cursor cursor = null; - try { - cursor = db.query(TABLE_NAME, new String[] {"COUNT(*)"}, THREAD_ID + " = ?", - new String[] {threadId+""}, null, null, null); + String[] cols = new String[] {"COUNT(*)"}; + String query = THREAD_ID + " = ?"; + String[] args = new String[]{String.valueOf(threadId)}; - if (cursor != null && cursor.moveToFirst()) + try (Cursor cursor = db.query(TABLE_NAME, cols, query, args, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { return cursor.getInt(0); - } finally { - if (cursor != null) - cursor.close(); + } + } + + return 0; + } + + public int getMessageCountForThread(long threadId, long beforeTime) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + + String[] cols = new String[] {"COUNT(*)"}; + String query = THREAD_ID + " = ? AND " + DATE_RECEIVED + " < ?"; + String[] args = new String[]{String.valueOf(threadId), String.valueOf(beforeTime)}; + + try (Cursor cursor = db.query(TABLE_NAME, cols, query, args, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + return cursor.getInt(0); + } } return 0; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java index 586a675537..1091bdd221 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -705,13 +705,13 @@ public class ThreadDatabase extends Database { } private @Nullable Extra getExtrasFor(MessageRecord record) { - boolean messageRequestAccepted = RecipientUtil.isThreadMessageRequestAccepted(context, record.getThreadId()); + boolean messageRequestAccepted = RecipientUtil.isMessageRequestAccepted(context, record.getThreadId()); RecipientId threadRecipientId = getRecipientIdForThreadId(record.getThreadId()); if (!messageRequestAccepted && threadRecipientId != null) { boolean isPushGroup = Recipient.resolved(threadRecipientId).isPushGroup(); if (isPushGroup) { - RecipientId recipientId = DatabaseFactory.getMmsSmsDatabase(context).getRecipientIdForLatestAdd(record.getThreadId()); + RecipientId recipientId = DatabaseFactory.getMmsSmsDatabase(context).getGroupAddedBy(record.getThreadId()); if (recipientId != null) { return Extra.forGroupMessageRequest(recipientId); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/loaders/ConversationLoader.java b/app/src/main/java/org/thoughtcrime/securesms/database/loaders/ConversationLoader.java index 2806e9c459..9cddaf700d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/loaders/ConversationLoader.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/loaders/ConversationLoader.java @@ -15,6 +15,7 @@ public class ConversationLoader extends AbstractCursorLoader { private long lastSeen; private boolean hasSent; private boolean isMessageRequestAccepted; + private boolean hasPreMessageRequestMessages; public ConversationLoader(Context context, long threadId, int offset, int limit, long lastSeen) { super(context); @@ -49,6 +50,10 @@ public class ConversationLoader extends AbstractCursorLoader { return isMessageRequestAccepted; } + public boolean hasPreMessageRequestMessages() { + return hasPreMessageRequestMessages; + } + @Override public Cursor getCursor() { Pair lastSeenAndHasSent = DatabaseFactory.getThreadDatabase(context).getLastSeenAndHasSent(threadId); @@ -59,7 +64,8 @@ public class ConversationLoader extends AbstractCursorLoader { this.lastSeen = lastSeenAndHasSent.first(); } - this.isMessageRequestAccepted = RecipientUtil.isThreadMessageRequestAccepted(context, threadId); + this.isMessageRequestAccepted = RecipientUtil.isMessageRequestAccepted(context, threadId); + this.hasPreMessageRequestMessages = RecipientUtil.isPreMessageRequestThread(context, threadId); return DatabaseFactory.getMmsSmsDatabase(context).getConversation(threadId, offset, limit); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMessageProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMessageProcessor.java index fb341f8774..c4e32eb1fd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMessageProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMessageProcessor.java @@ -27,6 +27,7 @@ import org.thoughtcrime.securesms.recipients.RecipientUtil; import org.thoughtcrime.securesms.sms.IncomingGroupMessage; import org.thoughtcrime.securesms.sms.IncomingTextMessage; import org.thoughtcrime.securesms.util.Base64; +import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.GroupUtil; import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; @@ -101,6 +102,13 @@ public class GroupMessageProcessor { database.create(id, group.getName().orNull(), members, avatar != null && avatar.isPointer() ? avatar.asPointer() : null, null); + Recipient sender = Recipient.externalPush(context, content.getSender()); + + if (FeatureFlags.messageRequests() && (sender.isSystemContact() || sender.isProfileSharing())) { + Log.i(TAG, "Auto-enabling profile sharing because 'adder' is trusted. contact: " + sender.isSystemContact() + ", profileSharing: " + sender.isProfileSharing()); + DatabaseFactory.getRecipientDatabase(context).setProfileSharing(Recipient.external(context, id).getId(), true); + } + return storeMessage(context, content, group, builder.build(), outgoing); } @@ -283,11 +291,11 @@ public class GroupMessageProcessor { GroupContext.Member.Builder member = GroupContext.Member.newBuilder(); if (address.getUuid().isPresent()) { - member = member.setUuid(address.getUuid().get().toString()); + member.setUuid(address.getUuid().get().toString()); } if (address.getNumber().isPresent()) { - member = member.setE164(address.getNumber().get()); + member.setE164(address.getNumber().get()); } return member.build(); 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 abc368f1b1..b4e30a6c35 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -51,6 +51,7 @@ public final class JobManagerFactories { put(CreateSignedPreKeyJob.KEY, new CreateSignedPreKeyJob.Factory()); put(DirectoryRefreshJob.KEY, new DirectoryRefreshJob.Factory()); put(FcmRefreshJob.KEY, new FcmRefreshJob.Factory()); + put(LeaveGroupJob.KEY, new LeaveGroupJob.Factory()); put(LocalBackupJob.KEY, new LocalBackupJob.Factory()); put(MmsDownloadJob.KEY, new MmsDownloadJob.Factory()); put(MmsReceiveJob.KEY, new MmsReceiveJob.Factory()); @@ -60,6 +61,7 @@ public final class JobManagerFactories { put(MultiDeviceContactUpdateJob.KEY, new MultiDeviceContactUpdateJob.Factory()); put(MultiDeviceGroupUpdateJob.KEY, new MultiDeviceGroupUpdateJob.Factory()); put(MultiDeviceKeysUpdateJob.KEY, new MultiDeviceKeysUpdateJob.Factory()); + put(MultiDeviceMessageRequestResponseJob.KEY, new MultiDeviceMessageRequestResponseJob.Factory()); put(MultiDeviceProfileContentUpdateJob.KEY, new MultiDeviceProfileContentUpdateJob.Factory()); put(MultiDeviceProfileKeyUpdateJob.KEY, new MultiDeviceProfileKeyUpdateJob.Factory()); put(MultiDeviceReadUpdateJob.KEY, new MultiDeviceReadUpdateJob.Factory()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/LeaveGroupJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/LeaveGroupJob.java new file mode 100644 index 0000000000..2d7d990639 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/LeaveGroupJob.java @@ -0,0 +1,179 @@ +package org.thoughtcrime.securesms.jobs; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.transport.RetryLaterException; +import org.thoughtcrime.securesms.util.Base64; +import org.thoughtcrime.securesms.util.GroupUtil; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceMessageSender; +import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair; +import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; +import org.whispersystems.signalservice.api.messages.SendMessageResult; +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; +import org.whispersystems.signalservice.api.messages.SignalServiceGroup; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * Normally, we can do group leaves via {@link PushGroupSendJob}. However, that job relies on a + * message being present in the database, which is not true if the user selects a message request + * option that deletes and leaves at the same time. + * + * This job tracks all send state within the job and does not require a message in the database to + * work. + */ +public class LeaveGroupJob extends BaseJob { + + public static final String KEY = "LeaveGroupJob"; + + private static final String TAG = Log.tag(LeaveGroupJob.class); + + private static final String KEY_GROUP_ID = "group_id"; + private static final String KEY_GROUP_NAME = "name"; + private static final String KEY_MEMBERS = "members"; + private static final String KEY_RECIPIENTS = "recipients"; + + private final byte[] groupId; + private final String name; + private final List members; + private final List recipients; + + public static @NonNull LeaveGroupJob create(@NonNull Recipient group) { + List members = Stream.of(group.resolve().getParticipants()).map(Recipient::getId).toList(); + members.remove(Recipient.self().getId()); + + return new LeaveGroupJob(GroupUtil.getDecodedIdOrThrow(group.getGroupId().get()), + group.resolve().getDisplayName(ApplicationDependencies.getApplication()), + members, + members, + new Parameters.Builder() + .setQueue(group.getId().toQueueKey()) + .addConstraint(NetworkConstraint.KEY) + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .setMaxAttempts(Parameters.UNLIMITED) + .build()); + } + + private LeaveGroupJob(@NonNull byte[] groupId, + @NonNull String name, + @NonNull List members, + @NonNull List recipients, + @NonNull Parameters parameters) + { + super(parameters); + this.groupId = groupId; + this.name = name; + this.members = Collections.unmodifiableList(members); + this.recipients = recipients; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putString(KEY_GROUP_ID, Base64.encodeBytes(groupId)) + .putString(KEY_GROUP_NAME, name) + .putString(KEY_MEMBERS, RecipientId.toSerializedList(members)) + .putString(KEY_RECIPIENTS, RecipientId.toSerializedList(recipients)) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + protected void onRun() throws Exception { + List completions = deliver(context, groupId, name, members, recipients); + + for (Recipient completion : completions) { + recipients.remove(completion.getId()); + } + + Log.i(TAG, "Completed now: " + completions.size() + ", Remaining: " + recipients.size()); + + if (!recipients.isEmpty()) { + Log.w(TAG, "Still need to send to " + recipients.size() + " recipients. Retrying."); + throw new RetryLaterException(); + } + } + + @Override + protected boolean onShouldRetry(@NonNull Exception e) { + return e instanceof IOException || e instanceof RetryLaterException; + } + + @Override + public void onFailure() { + } + + private static @NonNull List deliver(@NonNull Context context, + @NonNull byte[] groupId, + @NonNull String name, + @NonNull List members, + @NonNull List destinations) + throws IOException, UntrustedIdentityException + { + SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); + List addresses = Stream.of(destinations).map(Recipient::resolved).map(t -> RecipientUtil.toSignalServiceAddress(context, t)).toList(); + List memberAddresses = Stream.of(members).map(Recipient::resolved).map(t -> RecipientUtil.toSignalServiceAddress(context, t)).toList(); + List> unidentifiedAccess = Stream.of(destinations).map(Recipient::resolved).map(recipient -> UnidentifiedAccessUtil.getAccessFor(context, recipient)).toList(); + SignalServiceGroup serviceGroup = new SignalServiceGroup(SignalServiceGroup.Type.QUIT, groupId, name, memberAddresses, null); + SignalServiceDataMessage.Builder dataMessage = SignalServiceDataMessage.newBuilder() + .withTimestamp(System.currentTimeMillis()) + .asGroupMessage(serviceGroup); + + + List results = messageSender.sendMessage(addresses, unidentifiedAccess, false, dataMessage.build()); + + Stream.of(results) + .filter(r -> r.getIdentityFailure() != null) + .map(SendMessageResult::getAddress) + .map(a -> Recipient.externalPush(context, a)) + .forEach(r -> Log.w(TAG, "Identity failure for " + r.getId())); + + Stream.of(results) + .filter(SendMessageResult::isUnregisteredFailure) + .map(SendMessageResult::getAddress) + .map(a -> Recipient.externalPush(context, a)) + .forEach(r -> Log.w(TAG, "Unregistered failure for " + r.getId())); + + + return Stream.of(results) + .filter(r -> r.getSuccess() != null || r.getIdentityFailure() != null || r.isUnregisteredFailure()) + .map(SendMessageResult::getAddress) + .map(a -> Recipient.externalPush(context, a)) + .toList(); + } + + public static class Factory implements Job.Factory { + @Override + public @NonNull LeaveGroupJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new LeaveGroupJob(Base64.decodeOrThrow(data.getString(KEY_GROUP_ID)), + data.getString(KEY_GROUP_NAME), + RecipientId.fromSerializedList(data.getString(KEY_MEMBERS)), + RecipientId.fromSerializedList(data.getString(KEY_RECIPIENTS)), + parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceMessageRequestResponseJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceMessageRequestResponseJob.java new file mode 100644 index 0000000000..f58b5dc7fa --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceMessageRequestResponseJob.java @@ -0,0 +1,164 @@ +package org.thoughtcrime.securesms.jobs; + + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.util.GroupUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceMessageSender; +import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; +import org.whispersystems.signalservice.api.kbs.MasterKey; +import org.whispersystems.signalservice.api.messages.multidevice.KeysMessage; +import org.whispersystems.signalservice.api.messages.multidevice.MessageRequestResponseMessage; +import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +public class MultiDeviceMessageRequestResponseJob extends BaseJob { + + public static final String KEY = "MultiDeviceMessageRequestResponseJob"; + + private static final String TAG = MultiDeviceMessageRequestResponseJob.class.getSimpleName(); + + private static final String KEY_THREAD_RECIPIENT = "thread_recipient"; + private static final String KEY_TYPE = "type"; + + private final RecipientId threadRecipient; + private final Type type; + + public static @NonNull MultiDeviceMessageRequestResponseJob forAccept(@NonNull RecipientId threadRecipient) { + return new MultiDeviceMessageRequestResponseJob(threadRecipient, Type.ACCEPT); + } + + public static @NonNull MultiDeviceMessageRequestResponseJob forDelete(@NonNull RecipientId threadRecipient) { + return new MultiDeviceMessageRequestResponseJob(threadRecipient, Type.DELETE); + } + + public static @NonNull MultiDeviceMessageRequestResponseJob forBlock(@NonNull RecipientId threadRecipient) { + return new MultiDeviceMessageRequestResponseJob(threadRecipient, Type.BLOCK); + } + + public static @NonNull MultiDeviceMessageRequestResponseJob forBlockAndDelete(@NonNull RecipientId threadRecipient) { + return new MultiDeviceMessageRequestResponseJob(threadRecipient, Type.BLOCK_AND_DELETE); + } + + private MultiDeviceMessageRequestResponseJob(@NonNull RecipientId threadRecipient, @NonNull Type type) { + this(new Parameters.Builder() + .setQueue("MultiDeviceMessageRequestResponseJob") + .addConstraint(NetworkConstraint.KEY) + .setMaxAttempts(Parameters.UNLIMITED) + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .build(), threadRecipient, type); + + } + + private MultiDeviceMessageRequestResponseJob(@NonNull Parameters parameters, + @NonNull RecipientId threadRecipient, + @NonNull Type type) + { + super(parameters); + this.threadRecipient = threadRecipient; + this.type = type; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putString(KEY_THREAD_RECIPIENT, threadRecipient.serialize()) + .putInt(KEY_TYPE, type.serialize()) + .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, aborting..."); + return; + } + + SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); + Recipient recipient = Recipient.resolved(threadRecipient); + + + MessageRequestResponseMessage response; + + if (recipient.isGroup()) { + response = MessageRequestResponseMessage.forGroup(GroupUtil.getDecodedId(recipient.getGroupId().get()), localToRemoteType(type)); + } else { + response = MessageRequestResponseMessage.forIndividual(RecipientUtil.toSignalServiceAddress(context, recipient), localToRemoteType(type)); + } + + messageSender.sendMessage(SignalServiceSyncMessage.forMessageRequestResponse(response), + UnidentifiedAccessUtil.getAccessForSync(context)); + } + + private static MessageRequestResponseMessage.Type localToRemoteType(@NonNull Type type) { + switch (type) { + case ACCEPT: return MessageRequestResponseMessage.Type.ACCEPT; + case DELETE: return MessageRequestResponseMessage.Type.DELETE; + case BLOCK: return MessageRequestResponseMessage.Type.BLOCK; + case BLOCK_AND_DELETE: return MessageRequestResponseMessage.Type.BLOCK_AND_DELETE; + default: return MessageRequestResponseMessage.Type.UNKNOWN; + } + } + + @Override + public boolean onShouldRetry(@NonNull Exception e) { + return e instanceof PushNetworkException; + } + + @Override + public void onFailure() { + } + + private enum Type { + UNKNOWN(0), ACCEPT(1), DELETE(2), BLOCK(3), BLOCK_AND_DELETE(4); + + private final int value; + + Type(int value) { + this.value = value; + } + + int serialize() { + return value; + } + + static @NonNull Type deserialize(int value) { + for (Type type : Type.values()) { + if (type.value == value) { + return type; + } + } + throw new AssertionError("Unknown type: " + value); + } + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull + MultiDeviceMessageRequestResponseJob create(@NonNull Parameters parameters, @NonNull Data data) { + RecipientId threadRecipient = RecipientId.from(data.getString(KEY_THREAD_RECIPIENT)); + Type type = Type.deserialize(data.getInt(KEY_TYPE)); + + return new MultiDeviceMessageRequestResponseJob(parameters, threadRecipient, type); + } + } +} 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 5c48f9badd..e58e3538d3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java @@ -153,17 +153,19 @@ public class PushGroupSendJob extends PushSendJob { try { log(TAG, "Sending message: " + messageId); - if (FeatureFlags.messageRequests() && !message.getRecipient().resolve().isProfileSharing() && !database.isGroupQuitMessage(messageId)) { + if (!message.getRecipient().resolve().isProfileSharing() && !database.isGroupQuitMessage(messageId)) { RecipientUtil.shareProfileIfFirstSecureMessage(context, message.getRecipient()); } List target; + Recipient groupRecipient = message.getRecipient().fresh(); + if (filterRecipient != null) target = Collections.singletonList(Recipient.resolved(filterRecipient).getId()); else if (!existingNetworkFailures.isEmpty()) target = Stream.of(existingNetworkFailures).map(nf -> nf.getRecipientId(context)).toList(); - else target = getGroupMessageRecipients(message.getRecipient().requireGroupId(), messageId); + else target = getGroupMessageRecipients(groupRecipient.requireGroupId(), messageId); - List results = deliver(message, target); + List results = deliver(message, groupRecipient, target); List networkFailures = Stream.of(results).filter(SendMessageResult::isNetworkFailure).map(result -> new NetworkFailure(Recipient.externalPush(context, result.getAddress()).getId())).toList(); List identityMismatches = Stream.of(results).filter(result -> result.getIdentityFailure() != null).map(result -> new IdentityKeyMismatch(Recipient.externalPush(context, result.getAddress()).getId(), result.getIdentityFailure().getIdentityKey())).toList(); Set successIds = Stream.of(results).filter(result -> result.getSuccess() != null).map(SendMessageResult::getAddress).map(a -> Recipient.externalPush(context, a).getId()).collect(Collectors.toSet()); @@ -235,13 +237,13 @@ public class PushGroupSendJob extends PushSendJob { DatabaseFactory.getMmsDatabase(context).markAsSentFailed(messageId); } - private List deliver(OutgoingMediaMessage message, @NonNull List destinations) + private List deliver(OutgoingMediaMessage message, @NonNull Recipient groupRecipient, @NonNull List destinations) throws IOException, UntrustedIdentityException, UndeliverableMessageException { rotateSenderCertificateIfNecessary(); SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); - String groupId = message.getRecipient().requireGroupId(); - Optional profileKey = getProfileKey(message.getRecipient()); + String groupId = groupRecipient.requireGroupId(); + Optional profileKey = getProfileKey(groupRecipient); Optional quote = getQuoteFor(message); Optional sticker = getStickerFor(message); List sharedContacts = getSharedContactsFor(message); @@ -267,7 +269,7 @@ public class PushGroupSendJob extends PushSendJob { SignalServiceGroup group = new SignalServiceGroup(type, GroupUtil.getDecodedId(groupId), groupContext.getName(), members, avatar); SignalServiceDataMessage groupDataMessage = SignalServiceDataMessage.newBuilder() .withTimestamp(message.getSentTimeMillis()) - .withExpiration(message.getRecipient().getExpireMessages()) + .withExpiration(groupRecipient.getExpireMessages()) .asGroupMessage(group) .build(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushMediaSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushMediaSendJob.java index 03773b96bb..95e53ed47b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushMediaSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushMediaSendJob.java @@ -120,7 +120,7 @@ public class PushMediaSendJob extends PushSendJob { RecipientUtil.shareProfileIfFirstSecureMessage(context, message.getRecipient()); - Recipient recipient = message.getRecipient().resolve(); + Recipient recipient = message.getRecipient().fresh(); byte[] profileKey = recipient.getProfileKey(); UnidentifiedAccessMode accessMode = recipient.getUnidentifiedAccessMode(); @@ -193,11 +193,12 @@ public class PushMediaSendJob extends PushSendJob { try { rotateSenderCertificateIfNecessary(); + Recipient messageRecipient = message.getRecipient().fresh(); SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); - SignalServiceAddress address = getPushAddress(message.getRecipient()); + SignalServiceAddress address = getPushAddress(messageRecipient); List attachments = Stream.of(message.getAttachments()).filterNot(Attachment::isSticker).toList(); List serviceAttachments = getAttachmentPointersFor(attachments); - Optional profileKey = getProfileKey(message.getRecipient()); + Optional profileKey = getProfileKey(messageRecipient); Optional quote = getQuoteFor(message); Optional sticker = getStickerFor(message); List sharedContacts = getSharedContactsFor(message); @@ -223,7 +224,7 @@ public class PushMediaSendJob extends PushSendJob { messageSender.sendMessage(syncMessage, syncAccess); return syncAccess.isPresent(); } else { - return messageSender.sendMessage(address, UnidentifiedAccessUtil.getAccessFor(context, message.getRecipient()), mediaMessage).getSuccess().isUnidentified(); + return messageSender.sendMessage(address, UnidentifiedAccessUtil.getAccessFor(context, messageRecipient), mediaMessage).getSuccess().isUnidentified(); } } catch (UnregisteredUserException e) { warn(TAG, e); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java index 534d5a9716..9feb76b5a5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java @@ -95,6 +95,7 @@ import org.whispersystems.signalservice.api.messages.calls.OfferMessage; import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage; import org.whispersystems.signalservice.api.messages.multidevice.BlockedListMessage; import org.whispersystems.signalservice.api.messages.multidevice.ConfigurationMessage; +import org.whispersystems.signalservice.api.messages.multidevice.MessageRequestResponseMessage; import org.whispersystems.signalservice.api.messages.multidevice.ReadMessage; import org.whispersystems.signalservice.api.messages.multidevice.RequestMessage; import org.whispersystems.signalservice.api.messages.multidevice.SentTranscriptMessage; @@ -286,15 +287,16 @@ public final class PushProcessMessageJob extends BaseJob { SignalServiceSyncMessage syncMessage = content.getSyncMessage().get(); - 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.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()); - else if (syncMessage.getConfiguration().isPresent()) handleSynchronizeConfigurationMessage(syncMessage.getConfiguration().get()); - else if (syncMessage.getBlockedList().isPresent()) handleSynchronizeBlockedListMessage(syncMessage.getBlockedList().get()); - else if (syncMessage.getFetchType().isPresent()) handleSynchronizeFetchMessage(syncMessage.getFetchType().get()); + 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.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()); + else if (syncMessage.getConfiguration().isPresent()) handleSynchronizeConfigurationMessage(syncMessage.getConfiguration().get()); + else if (syncMessage.getBlockedList().isPresent()) handleSynchronizeBlockedListMessage(syncMessage.getBlockedList().get()); + else if (syncMessage.getFetchType().isPresent()) handleSynchronizeFetchMessage(syncMessage.getFetchType().get()); + else if (syncMessage.getMessageRequestResponse().isPresent()) handleSynchronizeMessageRequestResponse(syncMessage.getMessageRequestResponse().get()); else Log.w(TAG, "Contains no known sync types..."); } else if (content.getCallMessage().isPresent()) { Log.i(TAG, "Got call message..."); @@ -659,6 +661,48 @@ public final class PushProcessMessageJob extends BaseJob { } } + private void handleSynchronizeMessageRequestResponse(@NonNull MessageRequestResponseMessage response) { + RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context); + ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context); + + Recipient recipient; + + if (response.getPerson().isPresent()) { + recipient = Recipient.externalPush(context, response.getPerson().get()); + } else if (response.getGroupId().isPresent()) { + String groupId = GroupUtil.getEncodedId(response.getGroupId().get(), false); + recipient = Recipient.externalGroup(context, groupId); + } else { + Log.w(TAG, "Message request response was missing a thread recipient! Skipping."); + return; + } + + long threadId = threadDatabase.getThreadIdFor(recipient); + + switch (response.getType()) { + case ACCEPT: + recipientDatabase.setProfileSharing(recipient.getId(), true); + recipientDatabase.setBlocked(recipient.getId(), false); + break; + case DELETE: + recipientDatabase.setProfileSharing(recipient.getId(), false); + if (threadId > 0) threadDatabase.deleteConversation(threadId); + break; + case BLOCK: + recipientDatabase.setBlocked(recipient.getId(), true); + recipientDatabase.setProfileSharing(recipient.getId(), false); + break; + case BLOCK_AND_DELETE: + recipientDatabase.setBlocked(recipient.getId(), true); + recipientDatabase.setProfileSharing(recipient.getId(), false); + if (threadId > 0) threadDatabase.deleteConversation(threadId); + break; + default: + Log.w(TAG, "Got an unknown response type! Skipping"); + break; + } + } + private void handleSynchronizeSentMessage(@NonNull SignalServiceContent content, @NonNull SentTranscriptMessage message) throws StorageFailedException diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushTextSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushTextSendJob.java index 8a4018ddc4..0b3bc26c9e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushTextSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushTextSendJob.java @@ -83,7 +83,7 @@ public class PushTextSendJob extends PushSendJob { RecipientUtil.shareProfileIfFirstSecureMessage(context, record.getRecipient()); - Recipient recipient = record.getRecipient().resolve(); + Recipient recipient = record.getRecipient().fresh(); byte[] profileKey = recipient.getProfileKey(); UnidentifiedAccessMode accessMode = recipient.getUnidentifiedAccessMode(); @@ -154,10 +154,11 @@ public class PushTextSendJob extends PushSendJob { try { rotateSenderCertificateIfNecessary(); + Recipient messageRecipient = message.getIndividualRecipient().fresh(); SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); - SignalServiceAddress address = getPushAddress(message.getIndividualRecipient()); - Optional profileKey = getProfileKey(message.getIndividualRecipient()); - Optional unidentifiedAccess = UnidentifiedAccessUtil.getAccessFor(context, message.getIndividualRecipient()); + SignalServiceAddress address = getPushAddress(messageRecipient); + Optional profileKey = getProfileKey(messageRecipient); + Optional unidentifiedAccess = UnidentifiedAccessUtil.getAccessFor(context, messageRecipient); log(TAG, "Have access key to use: " + unidentifiedAccess.isPresent()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RemoteConfigRefreshJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RemoteConfigRefreshJob.java index 50cf9e42cc..27d3df9d7d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RemoteConfigRefreshJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RemoteConfigRefreshJob.java @@ -43,7 +43,6 @@ public class RemoteConfigRefreshJob extends BaseJob { protected void onRun() throws Exception { Map config = ApplicationDependencies.getSignalServiceAccountManager().getRemoteConfig(); FeatureFlags.update(config); - SignalStore.setRemoteConfigLastFetchTime(System.currentTimeMillis()); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/SendReadReceiptJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/SendReadReceiptJob.java index 55f44e11c3..06bf24fc75 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/SendReadReceiptJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/SendReadReceiptJob.java @@ -92,7 +92,7 @@ public class SendReadReceiptJob extends BaseJob { public void onRun() throws IOException, UntrustedIdentityException { if (!TextSecurePreferences.isReadReceiptsEnabled(context) || messageIds.isEmpty()) return; - if (!RecipientUtil.isThreadMessageRequestAccepted(context, threadId)) { + if (!RecipientUtil.isMessageRequestAccepted(context, threadId)) { Log.w(TAG, "Refusing to send receipts to untrusted recipient"); return; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RemoteConfigValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RemoteConfigValues.java new file mode 100644 index 0000000000..5da9d45964 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RemoteConfigValues.java @@ -0,0 +1,42 @@ +package org.thoughtcrime.securesms.keyvalue; + +import org.thoughtcrime.securesms.logging.Log; + +public final class RemoteConfigValues { + + private static final String TAG = Log.tag(RemoteConfigValues.class); + + private static final String CURRENT_CONFIG = "remote_config"; + private static final String PENDING_CONFIG = "pending_remote_config"; + private static final String LAST_FETCH_TIME = "remote_config_last_fetch_time"; + + private final KeyValueStore store; + + RemoteConfigValues(KeyValueStore store) { + this.store = store; + } + + public String getCurrentConfig() { + return store.getString(CURRENT_CONFIG, null); + } + + public void setCurrentConfig(String value) { + store.beginWrite().putString(CURRENT_CONFIG, value).apply(); + } + + public String getPendingConfig() { + return store.getString(PENDING_CONFIG, getCurrentConfig()); + } + + public void setPendingConfig(String value) { + store.beginWrite().putString(PENDING_CONFIG, value).apply(); + } + + public long getLastFetchTime() { + return store.getLong(LAST_FETCH_TIME, 0); + } + + public void setLastFetchTime(long time) { + store.beginWrite().putLong(LAST_FETCH_TIME, time).apply(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java index 62e938cf72..cb526e3a9e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java @@ -1,7 +1,5 @@ package org.thoughtcrime.securesms.keyvalue; -import android.content.Context; - import androidx.annotation.NonNull; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; @@ -12,9 +10,8 @@ import org.thoughtcrime.securesms.logging.SignalUncaughtExceptionHandler; */ public final class SignalStore { - private static final String REMOTE_CONFIG = "remote_config"; - private static final String REMOTE_CONFIG_LAST_FETCH_TIME = "remote_config_last_fetch_time"; private static final String LAST_PREKEY_REFRESH_TIME = "last_prekey_refresh_time"; + private static final String MESSAGE_REQUEST_ENABLE_TIME = "message_request_enable_time"; private SignalStore() {} @@ -30,20 +27,8 @@ public final class SignalStore { return new PinValues(getStore()); } - public static String getRemoteConfig() { - return getStore().getString(REMOTE_CONFIG, null); - } - - public static void setRemoteConfig(String value) { - putString(REMOTE_CONFIG, value); - } - - public static long getRemoteConfigLastFetchTime() { - return getStore().getLong(REMOTE_CONFIG_LAST_FETCH_TIME, 0); - } - - public static void setRemoteConfigLastFetchTime(long time) { - putLong(REMOTE_CONFIG_LAST_FETCH_TIME, time); + public static @NonNull RemoteConfigValues remoteConfigValues() { + return new RemoteConfigValues(getStore()); } public static long getLastPrekeyRefreshTime() { @@ -54,6 +39,14 @@ public final class SignalStore { putLong(LAST_PREKEY_REFRESH_TIME, time); } + public static long getMessageRequestEnableTime() { + return getStore().getLong(MESSAGE_REQUEST_ENABLE_TIME, 0); + } + + public static void setMessageRequestEnableTime(long time) { + putLong(MESSAGE_REQUEST_ENABLE_TIME, time); + } + /** * Ensures any pending writes are finished. Only intended to be called by * {@link SignalUncaughtExceptionHandler}. diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java index 4d8044fc8d..9c1ec7585c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java @@ -24,7 +24,6 @@ import org.thoughtcrime.securesms.mms.OutgoingSecureMediaMessage; import org.thoughtcrime.securesms.mms.Slide; import org.thoughtcrime.securesms.providers.BlobProvider; import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.recipients.RecipientUtil; import org.thoughtcrime.securesms.sms.MessageSender; import org.thoughtcrime.securesms.sms.MessageSender.PreUploadResult; import org.thoughtcrime.securesms.util.DiffHelper; diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java index b20ca3dd37..558b8f4bb8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java @@ -1,7 +1,6 @@ package org.thoughtcrime.securesms.messagerequests; import android.content.Context; -import android.database.Cursor; import androidx.annotation.NonNull; import androidx.core.util.Consumer; @@ -9,56 +8,62 @@ import androidx.core.util.Consumer; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.GroupDatabase; import org.thoughtcrime.securesms.database.MessagingDatabase; -import org.thoughtcrime.securesms.database.MmsSmsDatabase; import org.thoughtcrime.securesms.database.RecipientDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase; -import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobs.MultiDeviceMessageRequestResponseJob; import org.thoughtcrime.securesms.notifications.MarkReadReceiver; import org.thoughtcrime.securesms.notifications.MessageNotifier; import org.thoughtcrime.securesms.recipients.LiveRecipient; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; -import org.thoughtcrime.securesms.util.concurrent.SimpleTask; import org.whispersystems.libsignal.util.guava.Optional; import java.util.List; +import java.util.concurrent.Executor; public class MessageRequestRepository { - private final Context context; + private final Context context; + private final Executor executor; - public MessageRequestRepository(@NonNull Context context) { - this.context = context.getApplicationContext(); + MessageRequestRepository(@NonNull Context context) { + this.context = context.getApplicationContext(); + this.executor = SignalExecutors.BOUNDED; } - public LiveRecipient getLiveRecipient(@NonNull RecipientId recipientId) { - return Recipient.live(recipientId); - } - - public void getGroups(@NonNull RecipientId recipientId, @NonNull Consumer> onGroupsLoaded) { - SimpleTask.run(() -> { + void getGroups(@NonNull RecipientId recipientId, @NonNull Consumer> onGroupsLoaded) { + executor.execute(() -> { GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); - return groupDatabase.getGroupNamesContainingMember(recipientId); - }, onGroupsLoaded::accept); + onGroupsLoaded.accept(groupDatabase.getGroupNamesContainingMember(recipientId)); + }); } - public void getMemberCount(@NonNull RecipientId recipientId, @NonNull Consumer onMemberCountLoaded) { - SimpleTask.run(() -> { - GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); - Optional groupRecord = groupDatabase.getGroup(recipientId); - return groupRecord.transform(record -> record.getMembers().size()).or(0); - }, onMemberCountLoaded::accept); + void getMemberCount(@NonNull RecipientId recipientId, @NonNull Consumer onMemberCountLoaded) { + executor.execute(() -> { + GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); + Optional groupRecord = groupDatabase.getGroup(recipientId); + onMemberCountLoaded.accept(groupRecord.transform(record -> record.getMembers().size()).or(0)); + }); } - public void getMessageRequestAccepted(long threadId, @NonNull Consumer recipientRequestAccepted) { - SimpleTask.run(() -> RecipientUtil.isThreadMessageRequestAccepted(context, threadId), - recipientRequestAccepted::accept); + void getMessageRequestState(@NonNull Recipient recipient, long threadId, @NonNull Consumer state) { + executor.execute(() -> { + if (!RecipientUtil.isMessageRequestAccepted(context, threadId)) { + state.accept(MessageRequestState.UNACCEPTED); + } else if (RecipientUtil.isPreMessageRequestThread(context, threadId) && !RecipientUtil.isLegacyProfileSharingAccepted(recipient)) { + state.accept(MessageRequestState.LEGACY); + } else { + state.accept(MessageRequestState.ACCEPTED); + } + }); } - public void acceptMessageRequest(@NonNull LiveRecipient liveRecipient, long threadId, @NonNull Runnable onMessageRequestAccepted) { - SimpleTask.run(() -> { + void acceptMessageRequest(@NonNull LiveRecipient liveRecipient, long threadId, @NonNull Runnable onMessageRequestAccepted) { + executor.execute(()-> { RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context); recipientDatabase.setProfileSharing(liveRecipient.getId(), true); liveRecipient.refresh(); @@ -68,24 +73,84 @@ public class MessageRequestRepository { MessageNotifier.updateNotification(context); MarkReadReceiver.process(context, messageIds); - return null; - }, v -> onMessageRequestAccepted.run()); + if (TextSecurePreferences.isMultiDevice(context)) { + ApplicationDependencies.getJobManager().add(MultiDeviceMessageRequestResponseJob.forAccept(liveRecipient.getId())); + } + + onMessageRequestAccepted.run(); + }); } - public void deleteMessageRequest(long threadId, @NonNull Runnable onMessageRequestDeleted) { - SimpleTask.run(() -> { + void deleteMessageRequest(@NonNull LiveRecipient recipient, long threadId, @NonNull Runnable onMessageRequestDeleted) { + executor.execute(() -> { ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context); threadDatabase.deleteConversation(threadId); - return null; - }, v -> onMessageRequestDeleted.run()); + + if (recipient.resolve().isGroup()) { + RecipientUtil.leaveGroup(context, recipient.get()); + } + + if (TextSecurePreferences.isMultiDevice(context)) { + ApplicationDependencies.getJobManager().add(MultiDeviceMessageRequestResponseJob.forDelete(recipient.getId())); + } + + onMessageRequestDeleted.run(); + }); } - public void blockMessageRequest(@NonNull LiveRecipient liveRecipient, @NonNull Runnable onMessageRequestBlocked) { - SimpleTask.run(() -> { + void blockMessageRequest(@NonNull LiveRecipient liveRecipient, @NonNull Runnable onMessageRequestBlocked) { + executor.execute(() -> { Recipient recipient = liveRecipient.resolve(); RecipientUtil.block(context, recipient); liveRecipient.refresh(); - return null; - }, v -> onMessageRequestBlocked.run()); + + if (TextSecurePreferences.isMultiDevice(context)) { + ApplicationDependencies.getJobManager().add(MultiDeviceMessageRequestResponseJob.forBlock(liveRecipient.getId())); + } + + onMessageRequestBlocked.run(); + }); + } + + void blockAndDeleteMessageRequest(@NonNull LiveRecipient liveRecipient, long threadId, @NonNull Runnable onMessageRequestBlocked) { + executor.execute(() -> { + Recipient recipient = liveRecipient.resolve(); + RecipientUtil.block(context, recipient); + liveRecipient.refresh(); + + DatabaseFactory.getThreadDatabase(context).deleteConversation(threadId); + + if (TextSecurePreferences.isMultiDevice(context)) { + ApplicationDependencies.getJobManager().add(MultiDeviceMessageRequestResponseJob.forBlockAndDelete(liveRecipient.getId())); + } + + onMessageRequestBlocked.run(); + }); + } + + void unblockAndAccept(@NonNull LiveRecipient liveRecipient, long threadId, @NonNull Runnable onMessageRequestUnblocked) { + executor.execute(() -> { + Recipient recipient = liveRecipient.resolve(); + RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context); + + RecipientUtil.unblock(context, recipient); + recipientDatabase.setProfileSharing(liveRecipient.getId(), true); + liveRecipient.refresh(); + + List messageIds = DatabaseFactory.getThreadDatabase(context) + .setEntireThreadRead(threadId); + MessageNotifier.updateNotification(context); + MarkReadReceiver.process(context, messageIds); + + if (TextSecurePreferences.isMultiDevice(context)) { + ApplicationDependencies.getJobManager().add(MultiDeviceMessageRequestResponseJob.forAccept(liveRecipient.getId())); + } + + onMessageRequestUnblocked.run(); + }); + } + + enum MessageRequestState { + ACCEPTED, UNACCEPTED, LEGACY } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestViewModel.java index 9f08118e94..3d501bc71e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestViewModel.java @@ -15,6 +15,7 @@ import org.thoughtcrime.securesms.recipients.LiveRecipient; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientForeverObserver; import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.SingleLiveEvent; import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; import org.thoughtcrime.securesms.util.livedata.LiveDataTriple; @@ -24,13 +25,13 @@ import java.util.List; public class MessageRequestViewModel extends ViewModel { - private final SingleLiveEvent status = new SingleLiveEvent<>(); - private final MutableLiveData recipient = new MutableLiveData<>(); - private final MutableLiveData> groups = new MutableLiveData<>(Collections.emptyList()); - private final MutableLiveData memberCount = new MutableLiveData<>(0); - private final MutableLiveData shouldDisplayMessageRequest = new MutableLiveData<>(); - private final LiveData recipientInfo = Transformations.map(new LiveDataTriple<>(recipient, memberCount, groups), - triple -> new RecipientInfo(triple.first(), triple.second(), triple.third())); + private final SingleLiveEvent status = new SingleLiveEvent<>(); + private final MutableLiveData recipient = new MutableLiveData<>(); + private final MutableLiveData> groups = new MutableLiveData<>(Collections.emptyList()); + private final MutableLiveData memberCount = new MutableLiveData<>(0); + private final MutableLiveData displayState = new MutableLiveData<>(); + private final LiveData recipientInfo = Transformations.map(new LiveDataTriple<>(recipient, memberCount, groups), + triple -> new RecipientInfo(triple.first(), triple.second(), triple.third())); private final MessageRequestRepository repository; @@ -39,11 +40,7 @@ public class MessageRequestViewModel extends ViewModel { @SuppressWarnings("CodeBlock2Expr") private final RecipientForeverObserver recipientObserver = recipient -> { - if (Recipient.self().equals(recipient) || recipient.isBlocked() || recipient.isForceSmsSelection() || !recipient.isRegistered()) { - shouldDisplayMessageRequest.setValue(false); - } else { - loadMessageRequestAccepted(); - } + loadMessageRequestAccepted(recipient); this.recipient.setValue(recipient); }; @@ -71,8 +68,8 @@ public class MessageRequestViewModel extends ViewModel { } } - public LiveData getShouldDisplayMessageRequest() { - return shouldDisplayMessageRequest; + public LiveData getMessageRequestDisplayState() { + return displayState; } public LiveData getRecipient() { @@ -83,28 +80,46 @@ public class MessageRequestViewModel extends ViewModel { return recipientInfo; } - public LiveData getMesasgeRequestStatus() { + public LiveData getMessageRequestStatus() { return status; } + public boolean shouldShowMessageRequest() { + return displayState.getValue() == DisplayState.DISPLAY_MESSAGE_REQUEST; + } + @MainThread - public void accept() { + public void onAccept() { repository.acceptMessageRequest(liveRecipient, threadId, () -> { - status.setValue(Status.ACCEPTED); + status.postValue(Status.ACCEPTED); }); } @MainThread - public void delete() { - repository.deleteMessageRequest(threadId, () -> { - status.setValue(Status.DELETED); + public void onDelete() { + repository.deleteMessageRequest(liveRecipient, threadId, () -> { + status.postValue(Status.DELETED); }); } @MainThread - public void block() { + public void onBlock() { repository.blockMessageRequest(liveRecipient, () -> { - status.setValue(Status.BLOCKED); + status.postValue(Status.BLOCKED); + }); + } + + @MainThread + public void onUnblock() { + repository.unblockAndAccept(liveRecipient, threadId, () -> { + status.postValue(Status.ACCEPTED); + }); + } + + @MainThread + public void onBlockAndDelete() { + repository.blockAndDeleteMessageRequest(liveRecipient, threadId, () -> { + status.postValue(Status.BLOCKED); }); } @@ -114,33 +129,35 @@ public class MessageRequestViewModel extends ViewModel { } private void loadGroups() { - repository.getGroups(liveRecipient.getId(), this.groups::setValue); + repository.getGroups(liveRecipient.getId(), this.groups::postValue); } private void loadMemberCount() { repository.getMemberCount(liveRecipient.getId(), memberCount -> { - this.memberCount.setValue(memberCount == null ? 0 : memberCount); + this.memberCount.postValue(memberCount == null ? 0 : memberCount); }); } @SuppressWarnings("ConstantConditions") - private void loadMessageRequestAccepted() { - repository.getMessageRequestAccepted(threadId, accepted -> shouldDisplayMessageRequest.setValue(!accepted)); - } - - public static class Factory implements ViewModelProvider.Factory { - - private final Context context; - - public Factory(Context context) { - this.context = context; + private void loadMessageRequestAccepted(@NonNull Recipient recipient) { + if (FeatureFlags.messageRequests() && recipient.isBlocked()) { + displayState.postValue(DisplayState.DISPLAY_MESSAGE_REQUEST); + return; } - @NonNull - @Override - public T create(@NonNull Class modelClass) { - return (T) new MessageRequestViewModel(new MessageRequestRepository(context.getApplicationContext())); - } + repository.getMessageRequestState(recipient, threadId, accepted -> { + switch (accepted) { + case ACCEPTED: + displayState.postValue(DisplayState.DISPLAY_NONE); + break; + case UNACCEPTED: + displayState.postValue(DisplayState.DISPLAY_MESSAGE_REQUEST); + break; + case LEGACY: + displayState.postValue(DisplayState.DISPLAY_LEGACY); + break; + } + }); } public static class RecipientInfo { @@ -174,4 +191,24 @@ public class MessageRequestViewModel extends ViewModel { DELETED, ACCEPTED } + + public enum DisplayState { + DISPLAY_MESSAGE_REQUEST, DISPLAY_LEGACY, DISPLAY_NONE + } + + public static class Factory implements ViewModelProvider.Factory { + + private final Context context; + + public Factory(Context context) { + this.context = context; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + //noinspection unchecked + return (T) new MessageRequestViewModel(new MessageRequestRepository(context.getApplicationContext())); + } + } + } diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsBottomView.java b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsBottomView.java index 54c7909e0b..d333486d41 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsBottomView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsBottomView.java @@ -5,9 +5,14 @@ import android.util.AttributeSet; import android.view.View; import android.widget.TextView; +import androidx.annotation.NonNull; import androidx.constraintlayout.widget.ConstraintLayout; +import androidx.constraintlayout.widget.Group; +import androidx.core.text.HtmlCompat; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.HtmlUtil; public class MessageRequestsBottomView extends ConstraintLayout { @@ -15,6 +20,11 @@ public class MessageRequestsBottomView extends ConstraintLayout { private View accept; private View block; private View delete; + private View bigDelete; + private View bigUnblock; + + private Group normalButtons; + private Group blockedButtons; public MessageRequestsBottomView(Context context) { super(context); @@ -34,14 +44,34 @@ public class MessageRequestsBottomView extends ConstraintLayout { inflate(getContext(), R.layout.message_request_bottom_bar, this); - question = findViewById(R.id.message_request_question); - accept = findViewById(R.id.message_request_accept); - block = findViewById(R.id.message_request_block); - delete = findViewById(R.id.message_request_delete); + question = findViewById(R.id.message_request_question); + accept = findViewById(R.id.message_request_accept); + block = findViewById(R.id.message_request_block); + delete = findViewById(R.id.message_request_delete); + bigDelete = findViewById(R.id.message_request_big_delete); + bigUnblock = findViewById(R.id.message_request_big_unblock); + normalButtons = findViewById(R.id.message_request_normal_buttons); + blockedButtons = findViewById(R.id.message_request_blocked_buttons); } - public void setQuestionText(CharSequence questionText) { - question.setText(questionText); + public void setRecipient(@NonNull Recipient recipient) { + if (recipient.isBlocked()) { + if (recipient.isGroup()) { + question.setText(R.string.MessageRequestBottomView_unblock_to_allow_group_members_to_add_you_to_this_group_again); + } else { + question.setText(HtmlCompat.fromHtml(getContext().getString(R.string.MessageRequestBottomView_unblock_s_to_message_and_call_each_other, HtmlUtil.bold(recipient.getDisplayName(getContext()))), 0)); + } + normalButtons.setVisibility(GONE); + blockedButtons.setVisibility(VISIBLE); + } else { + if (recipient.isGroup()) { + question.setText(HtmlCompat.fromHtml(getContext().getString(R.string.MessageRequestBottomView_do_you_want_to_join_the_group_s_they_wont_know_youve_seen_their_messages_until_you_accept, HtmlUtil.bold(recipient.getDisplayName(getContext()))), 0)); + } else { + question.setText(HtmlCompat.fromHtml(getContext().getString(R.string.MessageRequestBottomView_do_you_want_to_let_s_message_you_they_wont_know_youve_seen_their_messages_until_you_accept, HtmlUtil.bold(recipient.getDisplayName(getContext()))), 0)); + } + normalButtons.setVisibility(VISIBLE); + blockedButtons.setVisibility(GONE); + } } public void setAcceptOnClickListener(OnClickListener acceptOnClickListener) { @@ -50,9 +80,14 @@ public class MessageRequestsBottomView extends ConstraintLayout { public void setDeleteOnClickListener(OnClickListener deleteOnClickListener) { delete.setOnClickListener(deleteOnClickListener); + bigDelete.setOnClickListener(deleteOnClickListener); } public void setBlockOnClickListener(OnClickListener blockOnClickListener) { block.setOnClickListener(blockOnClickListener); } + + public void setUnblockOnClickListener(OnClickListener unblockOnClickListener) { + bigUnblock.setOnClickListener(unblockOnClickListener); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MessageNotifier.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/MessageNotifier.java index aa73a1ff45..596015d6c0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MessageNotifier.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MessageNotifier.java @@ -368,7 +368,7 @@ public class MessageNotifier { long timestamp = notifications.get(0).getTimestamp(); if (timestamp != 0) builder.setWhen(timestamp); - if (!KeyCachingService.isLocked(context) && RecipientUtil.isRecipientMessageRequestAccepted(context, recipient.resolve())) { + if (!KeyCachingService.isLocked(context) && RecipientUtil.isMessageRequestAccepted(context, recipient.resolve())) { ReplyMethod replyMethod = ReplyMethod.forRecipient(context, recipient); builder.addActions(notificationState.getMarkAsReadIntent(context, notificationId), diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java index a2b39d4b30..d78f74e5f3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java @@ -12,7 +12,6 @@ import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.AvatarImageView; import org.thoughtcrime.securesms.mms.GlideApp; import org.thoughtcrime.securesms.reactions.ReactionsLoader.Reaction; -import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.util.AvatarUtil; import java.util.Collections; 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 83e2b4b6c4..9303fe23dc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java @@ -230,6 +230,19 @@ public class Recipient { return Recipient.resolved(id); } + /** + * A version of {@link #external(Context, String)} that should be used when you know the + * identifier is a groupId. + */ + @WorkerThread + public static @NonNull Recipient externalGroup(@NonNull Context context, @NonNull String groupId) { + if (!GroupUtil.isEncodedGroup(groupId)) { + throw new IllegalArgumentException("Invalid groupId!"); + } + + return Recipient.resolved(DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId)); + } + /** * Returns a fully-populated {@link Recipient} based off of a string identifier, creating one in * the database if necessary. The identifier may be a uuid, phone number, email, @@ -706,7 +719,11 @@ public class Recipient { return contactUri != null; } - public Recipient resolve() { + /** + * If this recipient is missing crucial data, this will return a populated copy. Otherwise it + * returns itself. + */ + public @NonNull Recipient resolve() { if (resolving) { return live().resolve(); } else { @@ -718,6 +735,13 @@ public class Recipient { return resolving; } + /** + * Forces retrieving a fresh copy of the recipient, regardless of its state. + */ + public @NonNull Recipient fresh() { + return live().resolve(); + } + public @NonNull LiveRecipient live() { return ApplicationDependencies.getRecipientCache().getLive(id); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientUtil.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientUtil.java index b9782beb68..d1f0a61aaf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientUtil.java @@ -11,18 +11,18 @@ import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.GroupDatabase; -import org.thoughtcrime.securesms.database.MmsSmsDatabase; -import org.thoughtcrime.securesms.database.RecipientDatabase; import org.thoughtcrime.securesms.database.RecipientDatabase.RegisteredState; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob; +import org.thoughtcrime.securesms.jobs.LeaveGroupJob; import org.thoughtcrime.securesms.jobs.MultiDeviceBlockedUpdateJob; +import org.thoughtcrime.securesms.jobs.MultiDeviceMessageRequestResponseJob; import org.thoughtcrime.securesms.jobs.RotateProfileKeyJob; +import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.mms.OutgoingGroupMediaMessage; -import org.thoughtcrime.securesms.sms.MessageSender; import org.thoughtcrime.securesms.util.GroupUtil; import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.push.SignalServiceAddress; @@ -76,25 +76,13 @@ public class RecipientUtil { DatabaseFactory.getRecipientDatabase(context).setBlocked(resolved.getId(), true); - if (resolved.isGroup() && DatabaseFactory.getGroupDatabase(context).isActive(resolved.requireGroupId())) { - long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(resolved); - Optional leaveMessage = GroupUtil.createGroupLeaveMessage(context, resolved); - - if (threadId != -1 && leaveMessage.isPresent()) { - MessageSender.send(context, leaveMessage.get(), threadId, false, null); - - GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); - String groupId = resolved.requireGroupId(); - groupDatabase.setActive(groupId, false); - groupDatabase.remove(groupId, Recipient.self().getId()); - } else { - Log.w(TAG, "Failed to leave group. Can't block."); - Toast.makeText(context, R.string.RecipientPreferenceActivity_error_leaving_group, Toast.LENGTH_LONG).show(); - } + if (resolved.isGroup()) { + leaveGroup(context, recipient); } if (resolved.isSystemContact() || resolved.isProfileSharing()) { ApplicationDependencies.getJobManager().add(new RotateProfileKeyJob()); + DatabaseFactory.getRecipientDatabase(context).setProfileSharing(resolved.getId(), false); } ApplicationDependencies.getJobManager().add(new MultiDeviceBlockedUpdateJob()); @@ -108,43 +96,87 @@ public class RecipientUtil { DatabaseFactory.getRecipientDatabase(context).setBlocked(recipient.getId(), false); ApplicationDependencies.getJobManager().add(new MultiDeviceBlockedUpdateJob()); + + if (FeatureFlags.messageRequests()) { + ApplicationDependencies.getJobManager().add(MultiDeviceMessageRequestResponseJob.forAccept(recipient.getId())); + } } @WorkerThread - public static boolean isThreadMessageRequestAccepted(@NonNull Context context, long threadId) { + public static void leaveGroup(@NonNull Context context, @NonNull Recipient recipient) { + Recipient resolved = recipient.resolve(); + + if (!resolved.isGroup()) { + throw new AssertionError("Not a group!"); + } + + if (DatabaseFactory.getGroupDatabase(context).isActive(resolved.requireGroupId())) { + long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(resolved); + Optional leaveMessage = GroupUtil.createGroupLeaveMessage(context, resolved); + + if (threadId != -1 && leaveMessage.isPresent()) { + ApplicationDependencies.getJobManager().add(LeaveGroupJob.create(recipient)); + + GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); + String groupId = resolved.requireGroupId(); + groupDatabase.setActive(groupId, false); + groupDatabase.remove(groupId, Recipient.self().getId()); + } else { + Log.w(TAG, "Failed to leave group."); + Toast.makeText(context, R.string.RecipientPreferenceActivity_error_leaving_group, Toast.LENGTH_LONG).show(); + } + } else { + Log.i(TAG, "Group was already inactive. Skipping."); + } + } + + /** + * If true, the new message request UI does not need to be shown, and it's safe to send read + * receipts. + * + * Note that this does not imply that a user has explicitly accepted a message request -- it could + * also be the case that the thread in question is for a system contact or something of the like. + */ + @WorkerThread + public static boolean isMessageRequestAccepted(@NonNull Context context, long threadId) { + if (!FeatureFlags.messageRequests() || threadId < 0) { + return true; + } + + ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context); + Recipient threadRecipient = threadDatabase.getRecipientForThreadId(threadId); + + if (threadRecipient == null) { + return true; + } + + return isMessageRequestAccepted(context, threadId, threadRecipient); + } + + /** + * See {@link #isMessageRequestAccepted(Context, long)}. + */ + @WorkerThread + public static boolean isMessageRequestAccepted(@NonNull Context context, @Nullable Recipient threadRecipient) { + if (!FeatureFlags.messageRequests() || threadRecipient == null) { + return true; + } + + long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(threadRecipient); + return isMessageRequestAccepted(context, threadId, threadRecipient); + } + + /** + * @return True if a conversation existed before we enabled message requests, otherwise false. + */ + @WorkerThread + public static boolean isPreMessageRequestThread(@NonNull Context context, long threadId) { if (!FeatureFlags.messageRequests()) { return true; } - ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context); - Recipient recipient = threadDatabase.getRecipientForThreadId(threadId); - boolean hasSentSecureMessage = DatabaseFactory.getMmsSmsDatabase(context) - .getOutgoingSecureConversationCount(threadId) != 0; - boolean noSecureMessagesInThread = DatabaseFactory.getMmsSmsDatabase(context) - .getSecureConversationCount(threadId) == 0; - - if (recipient == null || hasSentSecureMessage || noSecureMessagesInThread) { - return true; - } - - Recipient resolved = recipient.resolve(); - - return resolved.isProfileSharing() || resolved.isSystemContact(); - } - - @WorkerThread - public static boolean isRecipientMessageRequestAccepted(@NonNull Context context, @Nullable Recipient recipient) { - if (recipient == null || !FeatureFlags.messageRequests()) return true; - - Recipient resolved = recipient.resolve(); - - long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdIfExistsFor(resolved); - boolean hasSentMessage = DatabaseFactory.getMmsSmsDatabase(context) - .getOutgoingSecureConversationCount(threadId) != 0; - boolean noSecureMessagesInThread = DatabaseFactory.getMmsSmsDatabase(context) - .getSecureConversationCount(threadId) == 0; - - return noSecureMessagesInThread || hasSentMessage || resolved.isProfileSharing() || resolved.isSystemContact(); + long beforeTime = SignalStore.getMessageRequestEnableTime(); + return DatabaseFactory.getMmsSmsDatabase(context).getConversationCount(threadId, beforeTime) > 0; } @WorkerThread @@ -153,12 +185,45 @@ public class RecipientUtil { return; } - long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdIfExistsFor(recipient); - boolean firstMessage = DatabaseFactory.getMmsSmsDatabase(context) - .getOutgoingSecureConversationCount(threadId) == 0; + long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdIfExistsFor(recipient); + + if (isPreMessageRequestThread(context, threadId)) { + return; + } + + boolean firstMessage = DatabaseFactory.getMmsSmsDatabase(context).getOutgoingSecureConversationCount(threadId) == 0; if (firstMessage) { DatabaseFactory.getRecipientDatabase(context).setProfileSharing(recipient.getId(), true); } } + + public static boolean isLegacyProfileSharingAccepted(@NonNull Recipient threadRecipient) { + return threadRecipient.isLocalNumber() || + threadRecipient.isProfileSharing() || + threadRecipient.isSystemContact() || + !threadRecipient.isRegistered(); + } + + @WorkerThread + private static boolean isMessageRequestAccepted(@NonNull Context context, long threadId, @NonNull Recipient threadRecipient) { + return threadRecipient.isLocalNumber() || + threadRecipient.isProfileSharing() || + threadRecipient.isSystemContact() || + threadRecipient.isForceSmsSelection() || + !threadRecipient.isRegistered() || + hasSentMessageInThread(context, threadId) || + noSecureMessagesInThread(context, threadId) || + isPreMessageRequestThread(context, threadId); + } + + @WorkerThread + private static boolean hasSentMessageInThread(@NonNull Context context, long threadId) { + return DatabaseFactory.getMmsSmsDatabase(context).getOutgoingSecureConversationCount(threadId) != 0; + } + + @WorkerThread + private static boolean noSecureMessagesInThread(@NonNull Context context, long threadId) { + return DatabaseFactory.getMmsSmsDatabase(context).getSecureConversationCount(threadId) == 0; + } } 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 ccbe3fd389..fbec7bdd83 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -28,7 +28,7 @@ import java.util.concurrent.TimeUnit; * are not yet ready to be activated. * * When creating a new flag: - * - Create a new string constant using {@link #generateKey(String)}) + * - Create a new string constant. This should almost certainly be prefixed with "android." * - Add a method to retrieve the value using {@link #getValue(String, boolean)}. You can also add * other checks here, like requiring other flags. * - If you want to be able to change a flag remotely, place it in {@link #REMOTE_CAPABLE}. @@ -38,22 +38,22 @@ import java.util.concurrent.TimeUnit; * Other interesting things you can do: * - Make a flag {@link #HOT_SWAPPABLE} * - Make a flag {@link #STICKY} + * - Register a listener for flag changes in {@link #FLAG_CHANGE_LISTENERS} */ public final class FeatureFlags { private static final String TAG = Log.tag(FeatureFlags.class); - private static final String PREFIX = "android."; - private static final long FETCH_INTERVAL = TimeUnit.HOURS.toMillis(2); + private static final long FETCH_INTERVAL = TimeUnit.HOURS.toMillis(2); - private static final String UUIDS = generateKey("uuids"); - private static final String MESSAGE_REQUESTS = generateKey("messageRequests"); - private static final String USERNAMES = generateKey("usernames"); - private static final String STORAGE_SERVICE = generateKey("storageService"); - private static final String PINS_FOR_ALL = generateKey("pinsForAll"); - private static final String PINS_MEGAPHONE_KILL_SWITCH = generateKey("pinsMegaphoneKillSwitch"); - private static final String PROFILE_NAMES_MEGAPHONE = generateKey("profileNamesMegaphone"); - private static final String VIDEO_TRIMMING = generateKey("videoTrimming"); + private static final String UUIDS = "android.uuids"; + private static final String MESSAGE_REQUESTS = "android.messageRequests"; + private static final String USERNAMES = "android.usernames"; + private static final String STORAGE_SERVICE = "android.storageService"; + private static final String PINS_FOR_ALL = "android.pinsForAll"; + private static final String PINS_MEGAPHONE_KILL_SWITCH = "android.pinsMegaphoneKillSwitch"; + private static final String PROFILE_NAMES_MEGAPHONE = "android.profileNamesMegaphone"; + private static final String VIDEO_TRIMMING = "android.videoTrimming"; /** * We will only store remote values for flags in this set. If you want a flag to be controllable @@ -97,17 +97,39 @@ public final class FeatureFlags { PINS_FOR_ALL ); + /** + * Listeners that are called when the value in {@link #REMOTE_VALUES} changes. That means that + * hot-swappable flags will have this invoked as soon as we know about that change, but otherwise + * these will only run during initialization. + * + * These can be called on any thread, including the main thread, so be careful! + * + * Also note that this doesn't play well with {@link #FORCED_VALUES} -- changes there will not + * trigger changes in this map, so you'll have to do some manually hacking to get yourself in the + * desired test state. + */ + private static final Map FLAG_CHANGE_LISTENERS = new HashMap() {{ + put(MESSAGE_REQUESTS, (change) -> SignalStore.setMessageRequestEnableTime(change == Change.ENABLED ? System.currentTimeMillis() : 0)); + }}; + private static final Map REMOTE_VALUES = new TreeMap<>(); private FeatureFlags() {} public static synchronized void init() { - REMOTE_VALUES.putAll(parseStoredConfig()); + Map current = parseStoredConfig(SignalStore.remoteConfigValues().getCurrentConfig()); + Map pending = parseStoredConfig(SignalStore.remoteConfigValues().getPendingConfig()); + Map changes = computeChanges(current, pending); + + SignalStore.remoteConfigValues().setCurrentConfig(mapToJson(pending)); + REMOTE_VALUES.putAll(pending); + triggerFlagChangeListeners(changes); + Log.i(TAG, "init() " + REMOTE_VALUES.toString()); } - public static synchronized void refresh() { - long timeSinceLastFetch = System.currentTimeMillis() - SignalStore.getRemoteConfigLastFetchTime(); + public static synchronized void refreshIfNecessary() { + long timeSinceLastFetch = System.currentTimeMillis() - SignalStore.remoteConfigValues().getLastFetchTime(); if (timeSinceLastFetch > FETCH_INTERVAL) { Log.i(TAG, "Scheduling remote config refresh."); @@ -118,13 +140,16 @@ public final class FeatureFlags { } public static synchronized void update(@NonNull Map config) { - Map memory = REMOTE_VALUES; - Map disk = parseStoredConfig(); - UpdateResult result = updateInternal(config, memory, disk, REMOTE_CAPABLE, HOT_SWAPPABLE, STICKY); + Map memory = REMOTE_VALUES; + Map disk = parseStoredConfig(SignalStore.remoteConfigValues().getPendingConfig()); + UpdateResult result = updateInternal(config, memory, disk, REMOTE_CAPABLE, HOT_SWAPPABLE, STICKY); - SignalStore.setRemoteConfig(mapToJson(result.getDisk()).toString()); + SignalStore.remoteConfigValues().setPendingConfig(mapToJson(result.getDisk())); REMOTE_VALUES.clear(); REMOTE_VALUES.putAll(result.getMemory()); + triggerFlagChangeListeners(result.getChanges()); + + SignalStore.remoteConfigValues().setLastFetchTime(System.currentTimeMillis()); Log.i(TAG, "[Memory] Before: " + memory.toString()); Log.i(TAG, "[Memory] After : " + result.getMemory().toString()); @@ -189,7 +214,7 @@ public final class FeatureFlags { /** Only for rendering debug info. */ public static synchronized @NonNull Map getDiskValues() { - return new TreeMap<>(parseStoredConfig()); + return new TreeMap<>(parseStoredConfig(SignalStore.remoteConfigValues().getCurrentConfig())); } /** Only for rendering debug info. */ @@ -239,11 +264,31 @@ public final class FeatureFlags { } }); - return new UpdateResult(newMemory, newDisk); + return new UpdateResult(newMemory, newDisk, computeChanges(localMemory, newMemory)); } - private static @NonNull String generateKey(@NonNull String key) { - return PREFIX + key; + @VisibleForTesting + static @NonNull Map computeChanges(@NonNull Map oldMap, @NonNull Map newMap) { + Map changes = new HashMap<>(); + Set allKeys = new HashSet<>(); + + allKeys.addAll(oldMap.keySet()); + allKeys.addAll(newMap.keySet()); + + for (String key : allKeys) { + Boolean oldValue = oldMap.get(key); + Boolean newValue = newMap.get(key); + + if (oldValue == null && newValue == null) { + throw new AssertionError("Should not be possible."); + } else if (oldValue != null && newValue == null) { + changes.put(key, Change.REMOVED); + } else if (newValue != oldValue) { + changes.put(key, newValue ? Change.ENABLED : Change.DISABLED); + } + } + + return changes; } private static boolean getValue(@NonNull String key, boolean defaultValue) { @@ -260,9 +305,8 @@ public final class FeatureFlags { return defaultValue; } - private static Map parseStoredConfig() { + private static Map parseStoredConfig(String stored) { Map parsed = new HashMap<>(); - String stored = SignalStore.getRemoteConfig(); if (TextUtils.isEmpty(stored)) { Log.i(TAG, "No remote config stored. Skipping."); @@ -278,14 +322,13 @@ public final class FeatureFlags { parsed.put(key, root.getBoolean(key)); } } catch (JSONException e) { - SignalStore.setRemoteConfig(null); throw new AssertionError("Failed to parse! Cleared storage."); } return parsed; } - private static JSONObject mapToJson(@NonNull Map map) { + private static @NonNull String mapToJson(@NonNull Map map) { try { JSONObject json = new JSONObject(); @@ -293,12 +336,23 @@ public final class FeatureFlags { json.put(entry.getKey(), (boolean) entry.getValue()); } - return json; + return json.toString(); } catch (JSONException e) { throw new AssertionError(e); } } + private static void triggerFlagChangeListeners(Map changes) { + for (Map.Entry change : changes.entrySet()) { + OnFlagChange listener = FLAG_CHANGE_LISTENERS.get(change.getKey()); + + if (listener != null) { + Log.i(TAG, "Triggering change listener for: " + change.getKey()); + listener.onFlagChange(change.getValue()); + } + } + } + private static final class MissingFlagRequirementError extends Error { } @@ -306,10 +360,12 @@ public final class FeatureFlags { static final class UpdateResult { private final Map memory; private final Map disk; + private final Map changes; - UpdateResult(@NonNull Map memory, @NonNull Map disk) { - this.memory = memory; - this.disk = disk; + UpdateResult(@NonNull Map memory, @NonNull Map disk, @NonNull Map changes) { + this.memory = memory; + this.disk = disk; + this.changes = changes; } public @NonNull Map getMemory() { @@ -319,6 +375,19 @@ public final class FeatureFlags { public @NonNull Map getDisk() { return disk; } + + public @NonNull Map getChanges() { + return changes; + } + } + + @VisibleForTesting + interface OnFlagChange { + void onFlagChange(@NonNull Change change); + } + + enum Change { + ENABLED, DISABLED, REMOVED } /** Read and write versioned profile information. */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/GroupUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/GroupUtil.java index b4a8e551bd..3f0b2abcc2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/GroupUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/GroupUtil.java @@ -1,7 +1,6 @@ package org.thoughtcrime.securesms.util; import android.content.Context; -import android.text.TextUtils; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -45,6 +44,14 @@ public class GroupUtil { return Hex.fromStringCondensed(groupId.split("!", 2)[1]); } + public static byte[] getDecodedIdOrThrow(String groupId) { + try { + return getDecodedId(groupId); + } catch (IOException e) { + throw new AssertionError(e); + } + } + public static boolean isEncodedGroup(@NonNull String groupId) { return groupId.startsWith(ENCODED_SIGNAL_GROUP_PREFIX) || groupId.startsWith(ENCODED_MMS_GROUP_PREFIX); } diff --git a/app/src/main/res/layout/message_request_bottom_bar.xml b/app/src/main/res/layout/message_request_bottom_bar.xml index 94d2bde62b..8fe4e4ec02 100644 --- a/app/src/main/res/layout/message_request_bottom_bar.xml +++ b/app/src/main/res/layout/message_request_bottom_bar.xml @@ -13,17 +13,19 @@ android:layout_marginBottom="11dp" android:textAppearance="@style/Signal.Text.MessageRequest.Description" android:paddingTop="16dp" - app:layout_constraintBottom_toTopOf="@id/message_request_block" + app:layout_constraintBottom_toTopOf="@id/message_request_button_barrier" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" - tools:text="Do you want to let Cayce Pollard message you? They won't know you've seen their message until you accept." /> + tools:text="Do you want to let J. Jonah Jameson message you? They won't know you've seen their message until you accept." />