From 9e5f64c431cf3ddf36343b8bc48d694a24007d6b Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Wed, 19 Feb 2020 18:08:34 -0400 Subject: [PATCH] Improve message requests, add megaphone. --- app/src/main/AndroidManifest.xml | 5 + .../securesms/GroupCreateActivity.java | 2 +- .../securesms/components/AvatarImageView.java | 26 +- .../securesms/components/MaskView.java | 7 + .../conversation/ConversationActivity.java | 173 +++++++----- .../conversation/ConversationBannerView.java | 91 +++++++ .../conversation/ConversationFragment.java | 179 +++++++++++-- .../ConversationReactionOverlay.java | 4 + .../ConversationListFragment.java | 34 ++- .../ConversationListItem.java | 24 ++ .../database/CursorRecyclerViewAdapter.java | 38 ++- .../securesms/database/GroupDatabase.java | 4 +- .../securesms/database/MessagingDatabase.java | 47 +++- .../securesms/database/MmsDatabase.java | 58 +++- .../securesms/database/MmsSmsDatabase.java | 59 ++++ .../securesms/database/SmsDatabase.java | 40 ++- .../securesms/database/ThreadDatabase.java | 57 +++- .../database/loaders/ConversationLoader.java | 8 + .../database/model/ThreadRecord.java | 17 +- .../securesms/jobmanager/Data.java | 21 ++ .../securesms/jobmanager/JobManager.java | 2 +- .../SendReadReceiptsJobMigration.java | 55 ++++ .../securesms/jobs/JobManagerFactories.java | 5 +- .../securesms/jobs/PushGroupSendJob.java | 6 + .../securesms/jobs/PushMediaSendJob.java | 3 + .../securesms/jobs/PushTextSendJob.java | 3 + .../securesms/jobs/SendReadReceiptJob.java | 15 +- .../mediasend/MediaSendViewModel.java | 1 + .../securesms/megaphone/Megaphone.java | 2 +- .../megaphone/MegaphoneRepository.java | 1 + .../megaphone/MegaphoneViewBuilder.java | 11 + .../securesms/megaphone/Megaphones.java | 37 ++- .../megaphone/PopupMegaphoneView.java | 86 ++++++ .../MessageRequestFragment.java | 172 ------------ .../MessageRequestFragmentState.java | 90 ------- .../MessageRequestFragmentViewModel.java | 139 ---------- .../MessageRequestMegaphoneActivity.java | 73 +++++ ...ory.java => MessageRequestRepository.java} | 45 ++-- .../MessageRequestViewModel.java | 177 ++++++++++++ .../MessageRequestsBottomView.java | 58 ++++ .../notifications/MarkReadReceiver.java | 20 +- .../securesms/profiles/ProfileName.java | 2 +- .../profiles/edit/EditProfileActivity.java | 1 + .../securesms/recipients/Recipient.java | 52 +++- .../securesms/recipients/RecipientUtil.java | 49 +++- .../securesms/util/FeatureFlags.java | 8 +- .../thoughtcrime/securesms/util/HtmlUtil.java | 9 + .../thoughtcrime/securesms/util/Triple.java | 43 +++ .../org/thoughtcrime/securesms/util/Util.java | 11 + .../util/livedata/LiveDataTriple.java | 136 ++++++++++ .../res/drawable-hdpi/ic_note_to_self.webp | Bin 128 -> 0 bytes .../res/drawable-mdpi/ic_note_to_self.webp | Bin 92 -> 0 bytes .../message_requests_megaphone.png | Bin 0 -> 3514 bytes .../res/drawable-xhdpi/ic_note_to_self.webp | Bin 114 -> 0 bytes .../message_requests_megaphone.png | Bin 0 -> 9474 bytes .../res/drawable-xxhdpi/ic_note_to_self.webp | Bin 154 -> 0 bytes .../message_requests_megaphone.png | Bin 0 -> 17357 bytes .../res/drawable-xxxhdpi/ic_note_to_self.webp | Bin 152 -> 0 bytes .../message_requests_megaphone.png | Bin 0 -> 26820 bytes app/src/main/res/drawable/ic_group_80.xml | 5 + ...outline_40.xml => ic_group_outline_34.xml} | 4 +- .../main/res/drawable/ic_group_outline_48.xml | 9 + app/src/main/res/drawable/ic_note_24.xml | 5 + app/src/main/res/drawable/ic_note_34.xml | 9 + app/src/main/res/drawable/ic_note_80.xml | 5 + app/src/main/res/drawable/ic_profile_80.xml | 5 + .../res/drawable/ic_profile_outline_48.xml | 9 + .../main/res/layout/conversation_activity.xml | 18 +- .../res/layout/conversation_banner_view.xml | 62 +++++ .../main/res/layout/conversation_fragment.xml | 4 + .../res/layout/conversation_item_banner.xml | 4 + .../cursor_adapter_header_footer_view.xml | 5 + .../res/layout/message_request_bottom_bar.xml | 54 ++++ .../res/layout/message_request_fragment.xml | 119 -------- .../message_requests_megaphone_activity.xml | 59 ++++ .../main/res/layout/popup_megaphone_view.xml | 62 +++++ .../menu/conversation_message_requests.xml | 8 + .../conversation_message_requests_group.xml | 14 + .../raw/lottie_message_requests_splash.json | 1 + app/src/main/res/values/strings.xml | 18 +- .../SendReadReceiptsJobMigrationTest.java | 102 +++++++ .../notifications/MarkReadReceiverTest.java | 101 +++++++ .../recipients/RecipientUtilTest.java | 253 ++++++++++++++++++ 83 files changed, 2406 insertions(+), 735 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationBannerView.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/jobmanager/migrations/SendReadReceiptsJobMigration.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/megaphone/PopupMegaphoneView.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestFragment.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestFragmentState.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestFragmentViewModel.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestMegaphoneActivity.java rename app/src/main/java/org/thoughtcrime/securesms/messagerequests/{MessageRequestFragmentRepository.java => MessageRequestRepository.java} (65%) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestViewModel.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsBottomView.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/util/HtmlUtil.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/util/Triple.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/util/livedata/LiveDataTriple.java delete mode 100644 app/src/main/res/drawable-hdpi/ic_note_to_self.webp delete mode 100644 app/src/main/res/drawable-mdpi/ic_note_to_self.webp create mode 100644 app/src/main/res/drawable-mdpi/message_requests_megaphone.png delete mode 100644 app/src/main/res/drawable-xhdpi/ic_note_to_self.webp create mode 100644 app/src/main/res/drawable-xhdpi/message_requests_megaphone.png delete mode 100644 app/src/main/res/drawable-xxhdpi/ic_note_to_self.webp create mode 100644 app/src/main/res/drawable-xxhdpi/message_requests_megaphone.png delete mode 100644 app/src/main/res/drawable-xxxhdpi/ic_note_to_self.webp create mode 100644 app/src/main/res/drawable-xxxhdpi/message_requests_megaphone.png create mode 100644 app/src/main/res/drawable/ic_group_80.xml rename app/src/main/res/drawable/{ic_group_outline_40.xml => ic_group_outline_34.xml} (92%) create mode 100644 app/src/main/res/drawable/ic_group_outline_48.xml create mode 100644 app/src/main/res/drawable/ic_note_24.xml create mode 100644 app/src/main/res/drawable/ic_note_34.xml create mode 100644 app/src/main/res/drawable/ic_note_80.xml create mode 100644 app/src/main/res/drawable/ic_profile_80.xml create mode 100644 app/src/main/res/drawable/ic_profile_outline_48.xml create mode 100644 app/src/main/res/layout/conversation_banner_view.xml create mode 100644 app/src/main/res/layout/conversation_item_banner.xml create mode 100644 app/src/main/res/layout/cursor_adapter_header_footer_view.xml create mode 100644 app/src/main/res/layout/message_request_bottom_bar.xml delete mode 100644 app/src/main/res/layout/message_request_fragment.xml create mode 100644 app/src/main/res/layout/message_requests_megaphone_activity.xml create mode 100644 app/src/main/res/layout/popup_megaphone_view.xml create mode 100644 app/src/main/res/menu/conversation_message_requests.xml create mode 100644 app/src/main/res/menu/conversation_message_requests_group.xml create mode 100644 app/src/main/res/raw/lottie_message_requests_splash.json create mode 100644 app/src/test/java/org/thoughtcrime/securesms/jobmanager/migrations/SendReadReceiptsJobMigrationTest.java create mode 100644 app/src/test/java/org/thoughtcrime/securesms/notifications/MarkReadReceiverTest.java create mode 100644 app/src/test/java/org/thoughtcrime/securesms/recipients/RecipientUtilTest.java diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index eb9b4f23d5..493dd4ef21 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -442,6 +442,11 @@ + + diff --git a/app/src/main/java/org/thoughtcrime/securesms/GroupCreateActivity.java b/app/src/main/java/org/thoughtcrime/securesms/GroupCreateActivity.java index 0e05503349..712ad2b0f2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/GroupCreateActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/GroupCreateActivity.java @@ -197,7 +197,7 @@ public class GroupCreateActivity extends PassphraseRequiredActionBarActivity recipientsEditor.setHint(R.string.recipients_panel__add_members); recipientsPanel.setPanelChangeListener(this); findViewById(R.id.contacts_button).setOnClickListener(new AddRecipientButtonListener()); - avatar.setImageDrawable(new ResourceContactPhoto(R.drawable.ic_group_outline_40, R.drawable.ic_group_outline_20).asDrawable(this, ContactColors.UNKNOWN_COLOR.toConversationColor(this))); + avatar.setImageDrawable(new ResourceContactPhoto(R.drawable.ic_group_outline_34, R.drawable.ic_group_outline_20).asDrawable(this, ContactColors.UNKNOWN_COLOR.toConversationColor(this))); avatar.setOnClickListener(view -> AvatarSelection.startAvatarSelection(this, false, false)); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java b/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java index e490bd6fde..278f3a44a0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java @@ -16,6 +16,7 @@ import androidx.appcompat.widget.AppCompatImageView; import com.bumptech.glide.load.engine.DiskCacheStrategy; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.color.MaterialColor; import org.thoughtcrime.securesms.contacts.avatars.ContactColors; import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto; import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto; @@ -49,10 +50,11 @@ public final class AvatarImageView extends AppCompatImageView { DARK_THEME_OUTLINE_PAINT.setAntiAlias(true); } - private int size; - private boolean inverted; - private Paint outlinePaint; - private OnClickListener listener; + private int size; + private boolean inverted; + private Paint outlinePaint; + private OnClickListener listener; + private Recipient.FallbackPhotoProvider fallbackPhotoProvider; private @Nullable RecipientContactPhoto recipientContactPhoto; private @NonNull Drawable unknownRecipientDrawable; @@ -102,6 +104,10 @@ public final class AvatarImageView extends AppCompatImageView { super.setOnClickListener(listener); } + public void setFallbackPhotoProvider(Recipient.FallbackPhotoProvider fallbackPhotoProvider) { + this.fallbackPhotoProvider = fallbackPhotoProvider; + } + public void setAvatar(@NonNull GlideRequests requestManager, @Nullable Recipient recipient, boolean quickContactEnabled) { if (recipient != null) { RecipientContactPhoto photo = new RecipientContactPhoto(recipient); @@ -111,8 +117,8 @@ public final class AvatarImageView extends AppCompatImageView { recipientContactPhoto = photo; Drawable fallbackContactPhotoDrawable = size == SIZE_SMALL - ? photo.recipient.getSmallFallbackContactPhotoDrawable(getContext(), inverted) - : photo.recipient.getFallbackContactPhotoDrawable(getContext(), inverted); + ? photo.recipient.getSmallFallbackContactPhotoDrawable(getContext(), inverted, fallbackPhotoProvider) + : photo.recipient.getFallbackContactPhotoDrawable(getContext(), inverted, fallbackPhotoProvider); if (photo.contactPhoto != null) { requestManager.load(photo.contactPhoto) @@ -130,7 +136,13 @@ public final class AvatarImageView extends AppCompatImageView { } else { recipientContactPhoto = null; requestManager.clear(this); - setImageDrawable(unknownRecipientDrawable); + if (fallbackPhotoProvider != null) { + setImageDrawable(fallbackPhotoProvider.getPhotoForRecipientWithoutName() + .asDrawable(getContext(), MaterialColor.STEEL.toAvatarColor(getContext()), inverted)); + } else { + setImageDrawable(unknownRecipientDrawable); + } + super.setOnClickListener(listener); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/MaskView.java b/app/src/main/java/org/thoughtcrime/securesms/components/MaskView.java index ea5504bc11..7f75d4b75d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/MaskView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/MaskView.java @@ -21,6 +21,7 @@ public class MaskView extends View { private ViewGroup activityContentView; private Paint maskPaint; private Rect drawingRect = new Rect(); + private float targetParentTranslationY; private final ViewTreeObserver.OnDrawListener onDrawListener = this::invalidate; @@ -63,6 +64,10 @@ public class MaskView extends View { invalidate(); } + public void setTargetParentTranslationY(float targetParentTranslationY) { + this.targetParentTranslationY = targetParentTranslationY; + } + @Override protected void onDraw(@NonNull Canvas canvas) { super.onDraw(canvas); @@ -75,6 +80,8 @@ public class MaskView extends View { activityContentView.offsetDescendantRectToMyCoords(target, drawingRect); drawingRect.bottom = Math.min(drawingRect.bottom, getBottom() - getPaddingBottom()); + drawingRect.top += targetParentTranslationY; + drawingRect.bottom += targetParentTranslationY; Bitmap mask = Bitmap.createBitmap(target.getWidth(), drawingRect.height(), Bitmap.Config.ARGB_8888); Canvas maskCanvas = new Canvas(mask); 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 4facf9f40a..2fdad3e8b0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java @@ -56,7 +56,6 @@ import android.view.View.OnKeyListener; import android.view.WindowManager; import android.view.inputmethod.EditorInfo; import android.widget.Button; -import android.widget.FrameLayout; import android.widget.ImageButton; import android.widget.TextView; import android.widget.Toast; @@ -71,7 +70,9 @@ 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; import com.annimon.stream.Stream; @@ -162,8 +163,8 @@ import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity; import org.thoughtcrime.securesms.mediasend.Media; import org.thoughtcrime.securesms.mediasend.MediaSendActivity; import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult; -import org.thoughtcrime.securesms.messagerequests.MessageRequestFragment; -import org.thoughtcrime.securesms.messagerequests.MessageRequestFragmentViewModel; +import org.thoughtcrime.securesms.messagerequests.MessageRequestViewModel; +import org.thoughtcrime.securesms.messagerequests.MessageRequestsBottomView; import org.thoughtcrime.securesms.mms.AttachmentManager; import org.thoughtcrime.securesms.mms.AttachmentManager.MediaType; import org.thoughtcrime.securesms.mms.AudioSlide; @@ -215,6 +216,7 @@ 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; @@ -308,7 +310,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity private TypingStatusTextWatcher typingTextWatcher; private ConversationSearchBottomBar searchNav; private MenuItem searchViewItem; - private FrameLayout messageRequestOverlay; + private MessageRequestsBottomView messageRequestBottomView; private ConversationReactionOverlay reactionOverlay; private AttachmentManager attachmentManager; @@ -319,6 +321,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity protected HidingLinearLayout quickAttachmentToggle; protected HidingLinearLayout inlineAttachmentToggle; private InputPanel inputPanel; + private View panelParent; private LinkPreviewViewModel linkPreviewViewModel; private ConversationSearchViewModel searchViewModel; @@ -331,9 +334,10 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity private int distributionType; private boolean archived; private boolean isSecureText; - private boolean isDefaultSms = true; - private boolean isMmsEnabled = true; - private boolean isSecurityInitialized = false; + private boolean isDefaultSms = true; + private boolean isMmsEnabled = true; + private boolean isSecurityInitialized = false; + private boolean shouldDisplayMessageRequestUi = true; private final IdentityRecordList identityRecords = new IdentityRecordList(); private final DynamicTheme dynamicTheme = new DynamicDarkToolbarTheme(); @@ -687,6 +691,20 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity MenuInflater inflater = this.getMenuInflater(); menu.clear(); + if (isInMessageRequest()) { + if (isActiveGroup()) { + inflater.inflate(R.menu.conversation_message_requests_group, menu); + } + + inflater.inflate(R.menu.conversation_message_requests, menu); + + if (recipient != null && recipient.get().isMuted()) inflater.inflate(R.menu.conversation_muted, menu); + else inflater.inflate(R.menu.conversation_unmuted, menu); + + super.onPrepareOptionsMenu(menu); + return true; + } + if (isSecureText) { if (recipient.get().getExpireMessages() > 0) { inflater.inflate(R.menu.conversation_expiring_on, menu); @@ -932,6 +950,8 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity } private void handleConversationSettings() { + if (isInMessageRequest()) return; + Intent intent = new Intent(ConversationActivity.this, RecipientPreferenceActivity.class); intent.putExtra(RecipientPreferenceActivity.RECIPIENT_ID, recipient.getId()); intent.putExtra(RecipientPreferenceActivity.CAN_HAVE_SAFETY_NUMBER_EXTRA, @@ -967,13 +987,8 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity .setMessage(bodyRes) .setNegativeButton(android.R.string.cancel, null) .setPositiveButton(R.string.ConversationActivity_unblock, (dialog, which) -> { - SimpleTask.run(() -> { + SignalExecutors.BOUNDED.execute(() -> { RecipientUtil.unblock(ConversationActivity.this, recipient.get()); - return RecipientUtil.isRecipientMessageRequestAccepted(ConversationActivity.this, recipient.get()); - }, messageRequestAccepted -> { - if (!messageRequestAccepted) { - onMessageRequest(); - } }); }).show(); } @@ -1200,7 +1215,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity } private boolean handleDisplayQuickContact() { - if (recipient.get().isGroup()) return false; + if (isInMessageRequest() || recipient.get().isGroup()) return false; if (recipient.get().getContactUri() != null) { ContactsContract.QuickContact.showQuickContact(ConversationActivity.this, titleView, recipient.get().getContactUri(), ContactsContract.QuickContact.MODE_LARGE, null); @@ -1612,28 +1627,29 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity } private void initializeViews() { - titleView = findViewById(R.id.conversation_title_view); - buttonToggle = ViewUtil.findById(this, R.id.button_toggle); - sendButton = ViewUtil.findById(this, R.id.send_button); - attachButton = ViewUtil.findById(this, R.id.attach_button); - composeText = ViewUtil.findById(this, R.id.embedded_text_editor); - charactersLeft = ViewUtil.findById(this, R.id.space_left); - emojiDrawerStub = ViewUtil.findStubById(this, R.id.emoji_drawer_stub); - attachmentKeyboardStub = ViewUtil.findStubById(this, R.id.attachment_keyboard_stub); - unblockButton = ViewUtil.findById(this, R.id.unblock_button); - makeDefaultSmsButton = ViewUtil.findById(this, R.id.make_default_sms_button); - registerButton = ViewUtil.findById(this, R.id.register_button); - composePanel = ViewUtil.findById(this, R.id.bottom_panel); - container = ViewUtil.findById(this, R.id.layout_container); - reminderView = ViewUtil.findStubById(this, R.id.reminder_stub); - unverifiedBannerView = ViewUtil.findStubById(this, R.id.unverified_banner_stub); - groupShareProfileView = ViewUtil.findStubById(this, R.id.group_share_profile_view_stub); - quickAttachmentToggle = ViewUtil.findById(this, R.id.quick_attachment_toggle); - inlineAttachmentToggle = ViewUtil.findById(this, R.id.inline_attachment_container); - inputPanel = ViewUtil.findById(this, R.id.bottom_panel); - searchNav = ViewUtil.findById(this, R.id.conversation_search_nav); - messageRequestOverlay = ViewUtil.findById(this, R.id.fragment_overlay_container); - reactionOverlay = ViewUtil.findById(this, R.id.conversation_reaction_scrubber); + titleView = findViewById(R.id.conversation_title_view); + buttonToggle = ViewUtil.findById(this, R.id.button_toggle); + sendButton = ViewUtil.findById(this, R.id.send_button); + attachButton = ViewUtil.findById(this, R.id.attach_button); + composeText = ViewUtil.findById(this, R.id.embedded_text_editor); + charactersLeft = ViewUtil.findById(this, R.id.space_left); + emojiDrawerStub = ViewUtil.findStubById(this, R.id.emoji_drawer_stub); + attachmentKeyboardStub = ViewUtil.findStubById(this, R.id.attachment_keyboard_stub); + unblockButton = ViewUtil.findById(this, R.id.unblock_button); + makeDefaultSmsButton = ViewUtil.findById(this, R.id.make_default_sms_button); + registerButton = ViewUtil.findById(this, R.id.register_button); + composePanel = ViewUtil.findById(this, R.id.bottom_panel); + container = ViewUtil.findById(this, R.id.layout_container); + reminderView = ViewUtil.findStubById(this, R.id.reminder_stub); + unverifiedBannerView = ViewUtil.findStubById(this, R.id.unverified_banner_stub); + groupShareProfileView = ViewUtil.findStubById(this, R.id.group_share_profile_view_stub); + quickAttachmentToggle = ViewUtil.findById(this, R.id.quick_attachment_toggle); + inlineAttachmentToggle = ViewUtil.findById(this, R.id.inline_attachment_container); + inputPanel = ViewUtil.findById(this, R.id.bottom_panel); + panelParent = ViewUtil.findById(this, R.id.conversation_activity_panel_parent); + searchNav = ViewUtil.findById(this, R.id.conversation_search_nav); + messageRequestBottomView = ViewUtil.findById(this, R.id.conversation_activity_message_request_bottom_bar); + reactionOverlay = ViewUtil.findById(this, R.id.conversation_reaction_scrubber); ImageButton quickCameraToggle = ViewUtil.findById(this, R.id.quick_camera_toggle); ImageButton inlineAttachmentButton = ViewUtil.findById(this, R.id.inline_attachment_button); @@ -2051,7 +2067,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity } private void setGroupShareProfileReminder(@NonNull Recipient recipient) { - if (!FeatureFlags.messageRequests() && recipient.isPushGroup() && !recipient.isProfileSharing()) { + if (!shouldDisplayMessageRequestUi && recipient.isPushGroup() && !recipient.isProfileSharing()) { groupShareProfileView.get().setRecipient(recipient); groupShareProfileView.get().setVisibility(View.VISIBLE); } else if (groupShareProfileView.resolved()) { @@ -2095,6 +2111,9 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity } } + private boolean isInMessageRequest() { + return messageRequestBottomView.getVisibility() == View.VISIBLE; + } private boolean isSingleConversation() { return getRecipient() != null && !getRecipient().isGroup(); @@ -2265,7 +2284,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity long id = fragment.stageOutgoingMessage(message); SimpleTask.run(() -> { - if (initiating) { + if (!FeatureFlags.messageRequests() && initiating) { DatabaseFactory.getRecipientDatabase(this).setProfileSharing(recipient.getId(), true); } @@ -2278,7 +2297,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity }, this::sendComplete); } - private void sendMediaMessage(final boolean forceSms, final long expiresIn, final boolean viewOnce, final int subscriptionId, boolean initiating) + private void sendMediaMessage(final boolean forceSms, final long expiresIn, final boolean viewOnce, final int subscriptionId, final boolean initiating) throws InvalidMessageException { Log.i(TAG, "Sending media message..."); @@ -2339,8 +2358,8 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity final long id = fragment.stageOutgoingMessage(outgoingMessage); SimpleTask.run(() -> { - if (initiating) { - DatabaseFactory.getRecipientDatabase(context).setProfileSharing(recipient.getId(), true); + if (!FeatureFlags.messageRequests() && initiating) { + DatabaseFactory.getRecipientDatabase(this).setProfileSharing(recipient.getId(), true); } return MessageSender.send(context, outgoingMessage, threadId, forceSms, () -> fragment.releaseOutgoingMessage(id)); @@ -2355,7 +2374,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity return future; } - private void sendTextMessage(final boolean forceSms, final long expiresIn, final int subscriptionId, final boolean initiatingConversation) + private void sendTextMessage(final boolean forceSms, final long expiresIn, final int subscriptionId, final boolean initiating) throws InvalidMessageException { if (!isDefaultSms && (!isSecureText || forceSms)) { @@ -2386,7 +2405,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity new AsyncTask() { @Override protected Long doInBackground(OutgoingTextMessage... messages) { - if (initiatingConversation) { + if (!FeatureFlags.messageRequests() && initiating) { DatabaseFactory.getRecipientDatabase(context).setProfileSharing(recipient.getId(), true); } @@ -2499,9 +2518,9 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity @Override public void onSuccess(final @NonNull Pair result) { boolean forceSms = sendButton.isManualSelection() && sendButton.getSelectedTransport().isSms(); + boolean initiating = threadId == -1; int subscriptionId = sendButton.getSelectedTransport().getSimSubscriptionId().or(-1); long expiresIn = recipient.get().getExpireMessages() * 1000L; - boolean initiating = threadId == -1; AudioSlide audioSlide = new AudioSlide(ConversationActivity.this, result.first(), result.second(), MediaUtil.AUDIO_AAC, true); SlideDeck slideDeck = new SlideDeck(); slideDeck.addSlide(audioSlide); @@ -2757,37 +2776,18 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity } @Override - public void onMessageRequest() { - long threadId = getIntent().getLongExtra(THREAD_ID_EXTRA, -1); - RecipientId recipientId = getIntent().getParcelableExtra(RECIPIENT_EXTRA); + public void onMessageRequest(@NonNull MessageRequestViewModel viewModel) { - if (threadId == -1) { - throw new IllegalStateException("MessageRequest is not supported here"); - } + messageRequestBottomView.setAcceptOnClickListener(v -> viewModel.accept()); + messageRequestBottomView.setDeleteOnClickListener(v -> viewModel.delete()); + messageRequestBottomView.setBlockOnClickListener(v -> viewModel.block()); - if (recipientId == null) { - Log.w(TAG, "onMessageRequest: " + threadId + ": null recipient. finishing..."); - finish(); - } - - Log.i(TAG, "onMessageRequest: " + threadId + ", " + recipientId.serialize()); - - MessageRequestFragmentViewModel.Factory factory = new MessageRequestFragmentViewModel.Factory(this, threadId, recipientId); - MessageRequestFragmentViewModel viewModel = ViewModelProviders.of(this, factory).get(MessageRequestFragmentViewModel.class); - MessageRequestFragment fragment = new MessageRequestFragment(); - - messageRequestOverlay.setVisibility(View.VISIBLE); - container.setVisibility(View.GONE); - getSupportFragmentManager().beginTransaction() - .add(R.id.fragment_overlay_container, fragment) - .commit(); - - viewModel.getState().observe(this, state -> { - switch (state.messageRequestState) { + viewModel.getRecipient().observe(this, this::presentMessageRequestBottomViewTo); + viewModel.getShouldDisplayMessageRequest().observe(this, this::handleShouldDisplayMessageRequest); + viewModel.getMesasgeRequestStatus().observe(this, status -> { + switch (status) { case ACCEPTED: - getSupportFragmentManager().popBackStack(); - messageRequestOverlay.setVisibility(View.GONE); - container.setVisibility(View.VISIBLE); + messageRequestBottomView.setVisibility(View.GONE); return; case DELETED: case BLOCKED: @@ -2796,6 +2796,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity }); } + @Override public void handleReaction(@NonNull View maskTarget, @NonNull MessageRecord messageRecord, @NonNull Toolbar.OnMenuItemClickListener toolbarListener, @@ -2803,7 +2804,12 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity { reactionOverlay.setOnToolbarItemClickedListener(toolbarListener); reactionOverlay.setOnHideListener(onHideListener); - reactionOverlay.show(this, maskTarget, messageRecord, inputPanel.getMeasuredHeight()); + reactionOverlay.show(this, maskTarget, messageRecord, panelParent.getMeasuredHeight()); + } + + @Override + public void onListVerticalTranslationChanged(float translationY) { + reactionOverlay.setListVerticalTranslation(translationY); } @Override @@ -2892,7 +2898,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity } @Override - public void onForwardClicked() { + public void onForwardClicked() { inputPanel.clearQuote(); } @@ -2903,6 +2909,19 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity updateLinkPreviewState(); } + private void handleShouldDisplayMessageRequest(boolean shouldDisplayMessageRequest) { + shouldDisplayMessageRequestUi = shouldDisplayMessageRequest; + setGroupShareProfileReminder(recipient.get()); + + if (getIntent().hasExtra(TEXT_EXTRA) || getIntent().hasExtra(MEDIA_EXTRA) || getIntent().hasExtra(STICKER_EXTRA) || (isPushGroupConversation() && !isActiveGroup())) { + messageRequestBottomView.setVisibility(View.GONE); + } else { + messageRequestBottomView.setVisibility(shouldDisplayMessageRequest ? View.VISIBLE : View.GONE); + } + + invalidateOptionsMenu(); + } + private class UnverifiedDismissedListener implements UnverifiedBannerView.DismissListener { @Override public void onDismissed(final List unverifiedIdentities) { @@ -2996,4 +3015,10 @@ 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)); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationBannerView.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationBannerView.java new file mode 100644 index 0000000000..328a5d08ba --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationBannerView.java @@ -0,0 +1,91 @@ +package org.thoughtcrime.securesms.conversation; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.constraintlayout.widget.ConstraintLayout; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.AvatarImageView; +import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.recipients.Recipient; + +public class ConversationBannerView extends ConstraintLayout { + + private AvatarImageView contactAvatar; + private TextView contactTitle; + private TextView contactSubtitle; + private TextView contactDescription; + + public ConversationBannerView(Context context) { + this(context, null); + } + + public ConversationBannerView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public ConversationBannerView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + inflate(getContext(), R.layout.conversation_banner_view, this); + + contactAvatar = findViewById(R.id.message_request_avatar); + contactTitle = findViewById(R.id.message_request_title); + contactSubtitle = findViewById(R.id.message_request_subtitle); + contactDescription = findViewById(R.id.message_request_description); + + contactAvatar.setFallbackPhotoProvider(new FallbackPhotoProvider()); + } + + public void setAvatar(@NonNull GlideRequests requests, @Nullable Recipient recipient) { + contactAvatar.setAvatar(requests, recipient, false); + } + + public void setTitle(@Nullable CharSequence title) { + contactTitle.setText(title); + } + + public void setSubtitle(@Nullable CharSequence subtitle) { + contactSubtitle.setText(subtitle); + } + + public void setDescription(@Nullable CharSequence description) { + contactDescription.setText(description); + } + + public void hideSubtitle() { + contactSubtitle.setVisibility(View.GONE); + } + + public void showDescription() { + contactDescription.setVisibility(View.VISIBLE); + } + + public void hideDescription() { + contactDescription.setVisibility(View.GONE); + } + + private static final class FallbackPhotoProvider extends Recipient.FallbackPhotoProvider { + @Override + public @NonNull FallbackContactPhoto getPhotoForRecipientWithoutName() { + return new ResourceContactPhoto(R.drawable.ic_profile_80); + } + + @Override + public @NonNull FallbackContactPhoto getPhotoForGroup() { + return new ResourceContactPhoto(R.drawable.ic_group_80); + } + + @Override + public @NonNull FallbackContactPhoto getPhotoForLocalNumber() { + return new ResourceContactPhoto(R.drawable.ic_note_80); + } + } +} 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 cf17354b7a..95670e6c0c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -49,7 +49,9 @@ import androidx.appcompat.view.ActionMode; import androidx.appcompat.widget.Toolbar; import androidx.core.app.ActivityCompat; import androidx.core.app.ActivityOptionsCompat; +import androidx.core.text.HtmlCompat; import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProviders; import androidx.loader.app.LoaderManager; import androidx.loader.content.Loader; import androidx.recyclerview.widget.LinearLayoutManager; @@ -63,7 +65,6 @@ 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; @@ -88,6 +89,7 @@ import org.thoughtcrime.securesms.linkpreview.LinkPreview; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.longmessage.LongMessageActivity; import org.thoughtcrime.securesms.mediasend.Media; +import org.thoughtcrime.securesms.messagerequests.MessageRequestViewModel; import org.thoughtcrime.securesms.mms.GlideApp; import org.thoughtcrime.securesms.mms.OutgoingMediaMessage; import org.thoughtcrime.securesms.mms.PartAuthority; @@ -100,12 +102,14 @@ import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.revealable.ViewOnceMessageActivity; import org.thoughtcrime.securesms.revealable.ViewOnceUtil; +import org.thoughtcrime.securesms.sharing.ShareActivity; import org.thoughtcrime.securesms.sms.MessageSender; import org.thoughtcrime.securesms.sms.OutgoingTextMessage; import org.thoughtcrime.securesms.stickers.StickerLocator; import org.thoughtcrime.securesms.stickers.StickerPackPreviewActivity; import org.thoughtcrime.securesms.util.CommunicationActions; import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.HtmlUtil; import org.thoughtcrime.securesms.util.SaveAttachmentTask; import org.thoughtcrime.securesms.util.StickyHeaderDecoration; import org.thoughtcrime.securesms.util.TextSecurePreferences; @@ -151,6 +155,7 @@ 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; @@ -162,6 +167,9 @@ public class ConversationFragment extends Fragment private View composeDivider; private View scrollToBottomButton; private TextView scrollDateHeader; + private ConversationBannerView conversationBanner; + private ConversationBannerView emptyConversationBanner; + private MessageRequestViewModel messageRequestViewModel; @Override public void onCreate(Bundle icicle) { @@ -172,10 +180,11 @@ public class ConversationFragment extends Fragment @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle bundle) { final View view = inflater.inflate(R.layout.conversation_fragment, container, false); - list = ViewUtil.findById(view, android.R.id.list); - composeDivider = ViewUtil.findById(view, R.id.compose_divider); - scrollToBottomButton = ViewUtil.findById(view, R.id.scroll_to_bottom_button); - scrollDateHeader = ViewUtil.findById(view, R.id.scroll_date_header); + list = ViewUtil.findById(view, android.R.id.list); + composeDivider = ViewUtil.findById(view, R.id.compose_divider); + scrollToBottomButton = ViewUtil.findById(view, R.id.scroll_to_bottom_button); + scrollDateHeader = ViewUtil.findById(view, R.id.scroll_date_header); + emptyConversationBanner = ViewUtil.findById(view, R.id.empty_conversation_banner); scrollToBottomButton.setOnClickListener(v -> scrollToBottom()); @@ -184,6 +193,10 @@ public class ConversationFragment extends Fragment list.setLayoutManager(layoutManager); list.setItemAnimator(null); + if (FeatureFlags.messageRequests()) { + conversationBanner = (ConversationBannerView) inflater.inflate(R.layout.conversation_item_banner, container, false); + } + topLoadMoreView = (ViewSwitcher) inflater.inflate(R.layout.load_more_header, container, false); bottomLoadMoreView = (ViewSwitcher) inflater.inflate(R.layout.load_more_header, container, false); initializeLoadMoreView(topLoadMoreView); @@ -193,18 +206,59 @@ public class ConversationFragment extends Fragment new ConversationItemSwipeCallback( messageRecord -> actionMode == null && - canReplyToMessage(isActionMessage(messageRecord), messageRecord), + canReplyToMessage(isActionMessage(messageRecord), messageRecord, shouldDisplayMessageRequest), this::handleReplyMessage ).attachToRecyclerView(list); + setupListLayoutListeners(); + return view; } + private void setupListLayoutListeners() { + if (!FeatureFlags.messageRequests()) { + return; + } + + list.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> setListVerticalTranslation()); + + list.addOnChildAttachStateChangeListener(new RecyclerView.OnChildAttachStateChangeListener() { + @Override + public void onChildViewAttachedToWindow(@NonNull View view) { + setListVerticalTranslation(); + } + + @Override + public void onChildViewDetachedFromWindow(@NonNull View view) { + setListVerticalTranslation(); + } + }); + } + + private void setListVerticalTranslation() { + int heightOfChildren = 0; + for (int i = 0; i < list.getChildCount(); i++) { + heightOfChildren += list.getChildAt(i).getMeasuredHeight(); + } + + Log.i(TAG, "Height of children: " + heightOfChildren + " my height: " + list.getMeasuredHeight()); + if (heightOfChildren > list.getMeasuredHeight()) { + list.setTranslationY(0); + list.setOverScrollMode(RecyclerView.OVER_SCROLL_IF_CONTENT_SCROLLS); + } else { + list.setTranslationY(heightOfChildren - list.getMeasuredHeight()); + list.setOverScrollMode(RecyclerView.OVER_SCROLL_NEVER); + } + + listener.onListVerticalTranslationChanged(list.getTranslationY()); + } + @Override public void onActivityCreated(Bundle bundle) { super.onActivityCreated(bundle); initializeResources(); + initializeMessageRequestViewModel(); initializeListAdapter(); } @@ -241,6 +295,7 @@ public class ConversationFragment extends Fragment } initializeResources(); + messageRequestViewModel.setConversationInfo(recipient.getId(), threadId); initializeListAdapter(); if (threadId == -1) { @@ -267,6 +322,86 @@ public class ConversationFragment extends Fragment scrollToLastSeenPosition(position); } + private void initializeMessageRequestViewModel() { + MessageRequestViewModel.Factory factory = new MessageRequestViewModel.Factory(requireContext()); + + messageRequestViewModel = ViewModelProviders.of(requireActivity(), factory).get(MessageRequestViewModel.class); + messageRequestViewModel.setConversationInfo(recipient.getId(), threadId); + + listener.onMessageRequest(messageRequestViewModel); + + messageRequestViewModel.getRecipientInfo().observe(getViewLifecycleOwner(), recipientInfo -> { + 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) { + + if (conversationBanner == null) { + return; + } + + Recipient recipient = recipientInfo.getRecipient(); + boolean isSelf = Recipient.self().equals(recipient); + int memberCount = recipientInfo.getGroupMemberCount(); + List groups = recipientInfo.getSharedGroups(); + + if (recipient != null) { + conversationBanner.setAvatar(GlideApp.with(context), recipient); + + String title = isSelf ? context.getString(R.string.note_to_self) : recipient.getDisplayName(context); + conversationBanner.setTitle(title); + + if (recipient.isGroup()) { + conversationBanner.setSubtitle(context.getResources().getQuantityString(R.plurals.MessageRequestProfileView_members, memberCount, memberCount)); + } else if (isSelf) { + conversationBanner.setSubtitle(context.getString(R.string.ConversationFragment__you_can_add_notes_for_yourself_in_this_conversation)); + } else { + String subtitle = recipient.getUsername().or(recipient.getE164()).orNull(); + + if (subtitle == null || subtitle.equals(title)) { + conversationBanner.hideSubtitle(); + } else { + conversationBanner.setSubtitle(subtitle); + } + } + } + + if (groups.isEmpty() || isSelf) { + conversationBanner.hideDescription(); + } else { + final String description; + + switch (groups.size()) { + case 1: + description = context.getString(R.string.MessageRequestProfileView_member_of_one_group, HtmlUtil.bold(groups.get(0))); + break; + case 2: + description = context.getString(R.string.MessageRequestProfileView_member_of_two_groups, HtmlUtil.bold(groups.get(0)), HtmlUtil.bold(groups.get(1))); + break; + case 3: + description = context.getString(R.string.MessageRequestProfileView_member_of_many_groups, HtmlUtil.bold(groups.get(0)), HtmlUtil.bold(groups.get(1)), HtmlUtil.bold(groups.get(2))); + break; + default: + int others = groups.size() - 2; + description = context.getString(R.string.MessageRequestProfileView_member_of_many_groups, + HtmlUtil.bold(groups.get(0)), + HtmlUtil.bold(groups.get(1)), + context.getResources().getQuantityString(R.plurals.MessageRequestProfileView_member_of_others, others, others)); + } + + conversationBanner.setDescription(HtmlCompat.fromHtml(description, 0)); + conversationBanner.showDescription(); + } + } + private void initializeResources() { this.recipient = Recipient.live(getActivity().getIntent().getParcelableExtra(ConversationActivity.RECIPIENT_EXTRA)); this.threadId = this.getActivity().getIntent().getLongExtra(ConversationActivity.THREAD_ID_EXTRA, -1); @@ -288,6 +423,10 @@ public class ConversationFragment extends Fragment setLastSeen(lastSeen); getLoaderManager().restartLoader(0, Bundle.EMPTY, this); + + emptyConversationBanner.setVisibility(View.GONE); + } else if (FeatureFlags.messageRequests() && threadId == -1) { + emptyConversationBanner.setVisibility(View.VISIBLE); } } @@ -419,15 +558,16 @@ 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)); + menu.findItem(R.id.menu_context_reply).setVisible(canReplyToMessage(actionMessage, messageRecord, shouldDisplayMessageRequest)); } menu.findItem(R.id.menu_context_copy).setVisible(!actionMessage && hasText); } - private static boolean canReplyToMessage(boolean actionMessage, MessageRecord messageRecord) { - return !actionMessage && - !messageRecord.isPending() && - !messageRecord.isFailed() && + private static boolean canReplyToMessage(boolean actionMessage, MessageRecord messageRecord, boolean isDisplayingMessageRequest) { + return !actionMessage && + !messageRecord.isPending() && + !messageRecord.isFailed() && + !isDisplayingMessageRequest && messageRecord.isSecure(); } @@ -462,6 +602,7 @@ public class ConversationFragment extends Fragment if (this.threadId != threadId) { this.threadId = threadId; + messageRequestViewModel.setConversationInfo(recipient.getId(), threadId); initializeListAdapter(); } } @@ -708,6 +849,8 @@ public class ConversationFragment extends Fragment if (cursor.getCount() >= PARTIAL_CONVERSATION_LIMIT && loader.hasLimit()) { adapter.setFooterView(topLoadMoreView); + } else if (FeatureFlags.messageRequests()) { + adapter.setFooterView(conversationBanner); } else { adapter.setFooterView(null); } @@ -717,11 +860,7 @@ public class ConversationFragment extends Fragment } if (FeatureFlags.messageRequests()) { - if (!loader.hasSent() && !recipient.get().isSystemContact() && !recipient.get().isProfileSharing() && !recipient.get().isBlocked() && recipient.get().isRegistered()) { - listener.onMessageRequest(); - } else { - clearHeaderIfNotTyping(adapter); - } + clearHeaderIfNotTyping(adapter); } else { if (!loader.hasSent() && !recipient.get().isSystemContact() && !recipient.get().isGroup() && recipient.get().getRegistered() == RecipientDatabase.RegisteredState.REGISTERED) { adapter.setHeaderView(unknownSenderView); @@ -751,8 +890,10 @@ public class ConversationFragment extends Fragment if (firstLoad) { if (startingPosition >= 0) { scrollToStartingPosition(startingPosition); - } else { + } else if (loader.isMessageRequestAccepted()) { scrollToLastSeenPosition(lastSeenPosition); + } else if (FeatureFlags.messageRequests()) { + list.post(() -> getListLayoutManager().scrollToPosition(adapter.getItemCount() - 1)); } firstLoad = false; } else if (previousOffset > 0) { @@ -898,12 +1039,13 @@ public class ConversationFragment extends Fragment void handleReplyMessage(MessageRecord messageRecord); void onMessageActionToolbarOpened(); void onForwardClicked(); - void onMessageRequest(); + void onMessageRequest(@NonNull MessageRequestViewModel viewModel); void handleReaction(@NonNull View maskTarget, @NonNull MessageRecord messageRecord, @NonNull Toolbar.OnMenuItemClickListener toolbarListener, @NonNull ConversationReactionOverlay.OnHideListener onHideListener); void onCursorChanged(); + void onListVerticalTranslationChanged(float translationY); } private class ConversationScrollListener extends OnScrollListener { @@ -1000,6 +1142,7 @@ public class ConversationFragment extends Fragment if (messageRecord.isSecure() && !messageRecord.isUpdate() && !recipient.get().isBlocked() && + !shouldDisplayMessageRequest && ((ConversationAdapter) list.getAdapter()).getSelectedItems().isEmpty()) { isReacting = true; diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java index 83712dfd70..e0a14e5b68 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java @@ -129,6 +129,10 @@ public final class ConversationReactionOverlay extends RelativeLayout { initAnimators(); } + public void setListVerticalTranslation(float translationY) { + maskView.setTargetParentTranslationY(translationY); + } + public void show(@NonNull Activity activity, @NonNull View maskTarget, @NonNull MessageRecord messageRecord, int maskPaddingBottom) { if (overlayState != OverlayState.HIDDEN) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java index b5c883e098..15894f1f26 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -40,6 +40,7 @@ import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; +import android.view.inputmethod.InputMethodManager; import android.widget.ImageView; import android.widget.TextView; import android.widget.Toast; @@ -115,9 +116,11 @@ import org.thoughtcrime.securesms.mms.GlideApp; import org.thoughtcrime.securesms.notifications.MarkReadReceiver; import org.thoughtcrime.securesms.notifications.MessageNotifier; import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.profiles.ProfileName; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.service.KeyCachingService; import org.thoughtcrime.securesms.util.AvatarUtil; +import org.thoughtcrime.securesms.util.ServiceUtil; import org.thoughtcrime.securesms.util.StickyHeaderDecoration; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.Util; @@ -133,6 +136,8 @@ import java.util.List; import java.util.Locale; import java.util.Set; +import static android.app.Activity.RESULT_OK; + public class ConversationListFragment extends MainFragment implements LoaderManager.LoaderCallbacks, ActionMode.Callback, @@ -141,6 +146,10 @@ public class ConversationListFragment extends MainFragment implements LoaderMana MainNavigator.BackHandler, MegaphoneActionController { + public static final short MESSAGE_REQUESTS_REQUEST_CODE_CREATE_NAME = 32562; + public static final short PROFILE_NAMES_REQUEST_CODE_CREATE_NAME = 18473; + public static final short PROFILE_NAMES_REQUEST_CODE_CONFIRM_NAME = 19563; + private static final String TAG = Log.tag(ConversationListFragment.class); private static final int[] EMPTY_IMAGES = new int[] { R.drawable.empty_inbox_1, @@ -310,14 +319,30 @@ public class ConversationListFragment extends MainFragment implements LoaderMana @Override public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { - if (requestCode == CreateKbsPinActivity.REQUEST_NEW_PIN && resultCode == CreateKbsPinActivity.RESULT_OK) { + if (resultCode != RESULT_OK) { + return; + } + + boolean isProfileCreatedRequestCode = requestCode == MESSAGE_REQUESTS_REQUEST_CODE_CREATE_NAME || + requestCode ==PROFILE_NAMES_REQUEST_CODE_CREATE_NAME; + + if (requestCode == CreateKbsPinActivity.REQUEST_NEW_PIN) { Snackbar.make(fab, R.string.ConfirmKbsPinFragment__pin_created, Snackbar.LENGTH_LONG).show(); viewModel.onMegaphoneCompleted(Megaphones.Event.PINS_FOR_ALL); + } else if (isProfileCreatedRequestCode) { + Snackbar.make(fab, R.string.ConversationListFragment__your_profile_name_has_been_created, Snackbar.LENGTH_LONG).show(); + + if (requestCode == MESSAGE_REQUESTS_REQUEST_CODE_CREATE_NAME) { + viewModel.onMegaphoneCompleted(Megaphones.Event.MESSAGE_REQUESTS); + } + } else if (requestCode == PROFILE_NAMES_REQUEST_CODE_CONFIRM_NAME) { + Snackbar.make(fab, R.string.ConversationListFragment__your_profile_name_has_been_saved, Snackbar.LENGTH_LONG).show(); } } @Override public void onConversationClicked(@NonNull ThreadRecord threadRecord) { + hideKeyboard(); getNavigator().goToConversation(threadRecord.getRecipient().getId(), threadRecord.getThreadId(), threadRecord.getDistributionType(), @@ -330,6 +355,7 @@ public class ConversationListFragment extends MainFragment implements LoaderMana SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> { return DatabaseFactory.getThreadDatabase(getContext()).getThreadIdIfExistsFor(contact); }, threadId -> { + hideKeyboard(); getNavigator().goToConversation(contact.getId(), threadId, ThreadDatabase.DistributionTypes.DEFAULT, @@ -344,6 +370,7 @@ public class ConversationListFragment extends MainFragment implements LoaderMana int startingPosition = DatabaseFactory.getMmsSmsDatabase(getContext()).getMessagePositionInConversation(message.threadId, message.receivedTimestampMs); return Math.max(0, startingPosition); }, startingPosition -> { + hideKeyboard(); getNavigator().goToConversation(message.conversationRecipient.getId(), message.threadId, ThreadDatabase.DistributionTypes.DEFAULT, @@ -382,6 +409,11 @@ public class ConversationListFragment extends MainFragment implements LoaderMana viewModel.onMegaphoneCompleted(event); } + private void hideKeyboard() { + InputMethodManager imm = ServiceUtil.getInputMethodManager(requireContext()); + imm.hideSoftInputFromWindow(requireView().getWindowToken(), 0); + } + private void initializeProfileIcon(@NonNull Recipient recipient) { ImageView icon = requireView().findViewById(R.id.toolbar_icon); 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 87ccb53d5f..bcbca1a918 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java @@ -49,6 +49,7 @@ 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; @@ -70,6 +71,7 @@ public class ConversationListItem extends RelativeLayout private Set selectedThreads; private LiveRecipient recipient; + private LiveRecipient groupAddedBy; private long threadId; private GlideRequests glideRequests; private View subjectContainer; @@ -82,6 +84,7 @@ public class ConversationListItem extends RelativeLayout private AlertView alertView; private TextView unreadIndicator; private long lastSeen; + private ThreadRecord thread; private int unreadCount; private AvatarImageView contactPhotoImage; @@ -89,6 +92,12 @@ public class ConversationListItem extends RelativeLayout private int distributionType; + private final RecipientForeverObserver groupAddedByObserver = adder -> { + if (isAttachedToWindow() && subjectView != null && thread != null) { + subjectView.setText(thread.getDisplayBody(getContext())); + } + }; + public ConversationListItem(Context context) { this(context, null); } @@ -137,6 +146,7 @@ public class ConversationListItem extends RelativeLayout @Nullable String highlightSubstring) { if (this.recipient != null) this.recipient.removeForeverObserver(this); + if (this.groupAddedBy != null) this.groupAddedBy.removeForeverObserver(groupAddedByObserver); this.selectedThreads = selectedThreads; this.recipient = thread.getRecipient().live(); @@ -145,6 +155,7 @@ public class ConversationListItem extends RelativeLayout this.unreadCount = thread.getUnreadCount(); this.distributionType = thread.getDistributionType(); this.lastSeen = thread.getLastSeen(); + this.thread = thread; this.recipient.observeForever(this); if (highlightSubstring != null) { @@ -166,6 +177,12 @@ public class ConversationListItem extends RelativeLayout this.subjectView.setVisibility(VISIBLE); this.subjectView.setText(getTrimmedSnippet(thread.getDisplayBody(getContext()))); + + if (thread.getGroupAddedBy() != null) { + groupAddedBy = Recipient.live(thread.getGroupAddedBy()); + groupAddedBy.observeForever(groupAddedByObserver); + } + this.subjectView.setTypeface(unreadCount == 0 ? LIGHT_TYPEFACE : BOLD_TYPEFACE); this.subjectView.setTextColor(unreadCount == 0 ? ThemeUtil.getThemedColor(getContext(), R.attr.conversation_list_item_subject_color) : ThemeUtil.getThemedColor(getContext(), R.attr.conversation_list_item_unread_color)); @@ -199,6 +216,7 @@ public class ConversationListItem extends RelativeLayout @Nullable String highlightSubstring) { if (this.recipient != null) this.recipient.removeForeverObserver(this); + if (this.groupAddedBy != null) this.groupAddedBy.removeForeverObserver(groupAddedByObserver); this.selectedThreads = Collections.emptySet(); this.recipient = contact.live(); @@ -227,6 +245,7 @@ public class ConversationListItem extends RelativeLayout @Nullable String highlightSubstring) { if (this.recipient != null) this.recipient.removeForeverObserver(this); + if (this.groupAddedBy != null) this.groupAddedBy.removeForeverObserver(groupAddedByObserver); this.selectedThreads = Collections.emptySet(); this.recipient = messageResult.conversationRecipient.live(); @@ -255,6 +274,11 @@ public class ConversationListItem extends RelativeLayout this.recipient = null; contactPhotoImage.setAvatar(glideRequests, null, true); } + + if (this.groupAddedBy != null) { + this.groupAddedBy.removeForeverObserver(groupAddedByObserver); + this.groupAddedBy = null; + } } private void setBatchState(boolean batch) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/CursorRecyclerViewAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/database/CursorRecyclerViewAdapter.java index 0b637d8fb0..d3f5218c56 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/CursorRecyclerViewAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/CursorRecyclerViewAdapter.java @@ -19,16 +19,18 @@ package org.thoughtcrime.securesms.database; import android.content.Context; import android.database.Cursor; import android.database.DataSetObserver; +import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; -import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView.ViewHolder; +import org.thoughtcrime.securesms.R; + /** * RecyclerView.Adapter that manages a Cursor, comparable to the CursorAdapter usable in ListView/GridView. */ @@ -47,8 +49,24 @@ public abstract class CursorRecyclerViewAdapter getGroupNamesContainingMember(RecipientId recipientId) { SQLiteDatabase database = databaseHelper.getReadableDatabase(); + String table = TABLE_NAME + " INNER JOIN " + ThreadDatabase.TABLE_NAME + " ON " + TABLE_NAME + "." + RECIPIENT_ID + " = " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.RECIPIENT_ID; List groupNames = new LinkedList<>(); String[] projection = new String[]{TITLE, MEMBERS}; String query = MEMBERS + " LIKE ?"; String[] args = new String[]{"%" + recipientId.serialize() + "%"}; + String orderBy = ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.DATE + " DESC"; - try (Cursor cursor = database.query(TABLE_NAME, projection, query, args, null, null, null)) { + try (Cursor cursor = database.query(table, projection, query, args, null, null, orderBy)) { while (cursor != null && cursor.moveToNext()) { List members = Util.split(cursor.getString(cursor.getColumnIndexOrThrow(MEMBERS)), ","); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java index e8ebf9d964..5c0b00d65e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java @@ -30,6 +30,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.Iterator; import java.util.List; +import java.util.Locale; public abstract class MessagingDatabase extends Database implements MmsSmsColumns { @@ -72,6 +73,36 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn return getMessageCountForRecipientsAndType(getOutgoingSecureMessageClause()); } + final int getSecureMessageCount(long threadId) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String[] projection = new String[] {"COUNT(*)"}; + String query = getSecureMessageClause() + "AND " + MmsSmsColumns.THREAD_ID + " = ?"; + String[] args = new String[]{String.valueOf(threadId)}; + + try (Cursor cursor = db.query(getTableName(), projection, query, args, null, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + return cursor.getInt(0); + } else { + return 0; + } + } + } + + final int getOutgoingSecureMessageCount(long threadId) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String[] projection = new String[] {"COUNT(*)"}; + String query = getOutgoingSecureMessageClause() + "AND " + MmsSmsColumns.THREAD_ID + " = ? AND" + "(" + getTypeField() + " & " + Types.GROUP_QUIT_BIT + " = 0)"; + String[] args = new String[]{String.valueOf(threadId)}; + + try (Cursor cursor = db.query(getTableName(), projection, query, args, null, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + return cursor.getInt(0); + } else { + return 0; + } + } + } + private int getMessageCountForRecipientsAndType(String typeClause) { SQLiteDatabase db = databaseHelper.getReadableDatabase(); @@ -96,6 +127,14 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn return "(" + getTypeField() + " & " + Types.BASE_TYPE_MASK + ") = " + Types.BASE_SENT_TYPE + " AND (" + getTypeField() + " & " + (Types.SECURE_MESSAGE_BIT | Types.PUSH_MESSAGE_BIT) + ")"; } + private String getSecureMessageClause() { + String isSent = "(" + getTypeField() + " & " + Types.BASE_TYPE_MASK + ") = " + Types.BASE_SENT_TYPE; + String isReceived = "(" + getTypeField() + " & " + Types.BASE_TYPE_MASK + ") = " + Types.BASE_INBOX_TYPE; + String isSecure = "(" + getTypeField() + " & " + (Types.SECURE_MESSAGE_BIT | Types.PUSH_MESSAGE_BIT) + ")"; + + return String.format(Locale.ENGLISH, "(%s OR %s) AND %s", isSent, isReceived, isSecure); + } + public void setReactionsSeen(long threadId) { SQLiteDatabase db = databaseHelper.getWritableDatabase(); ContentValues values = new ContentValues(); @@ -432,14 +471,20 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn public static class MarkedMessageInfo { + private final long threadId; private final SyncMessageId syncMessageId; private final ExpirationInfo expirationInfo; - public MarkedMessageInfo(SyncMessageId syncMessageId, ExpirationInfo expirationInfo) { + public MarkedMessageInfo(long threadId, SyncMessageId syncMessageId, ExpirationInfo expirationInfo) { + this.threadId = threadId; this.syncMessageId = syncMessageId; this.expirationInfo = expirationInfo; } + public long getThreadId() { + return threadId; + } + public SyncMessageId getSyncMessageId() { return syncMessageId; } 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 889646d817..ae76b8e124 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java @@ -20,11 +20,12 @@ import android.content.ContentValues; import android.content.Context; import android.database.Cursor; import android.net.Uri; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import android.text.TextUtils; import android.util.Pair; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + import com.annimon.stream.Collectors; import com.annimon.stream.Stream; import com.google.android.mms.pdu_alt.NotificationInd; @@ -81,7 +82,6 @@ import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Set; -import java.util.concurrent.TimeUnit; import static org.thoughtcrime.securesms.contactshare.Contact.Avatar; @@ -244,6 +244,42 @@ public class MmsDatabase extends MessagingDatabase { return MESSAGE_BOX; } + public boolean isGroupQuitMessage(long messageId) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + + String[] columns = new String[]{ID}; + String query = ID + " = ? AND " + MESSAGE_BOX + " & ?"; + long type = Types.getOutgoingEncryptedMessageType() | Types.GROUP_QUIT_BIT; + String[] args = new String[]{String.valueOf(messageId), String.valueOf(type)}; + + try (Cursor cursor = db.query(TABLE_NAME, columns, query, args, null, null, null, null)) { + if (cursor.getCount() == 1) { + return true; + } + } + + return false; + } + + public long getLatestGroupQuitTimestamp(long threadId, long quitTimeBarrier) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + + String[] columns = new String[]{DATE_SENT}; + String query = THREAD_ID + " = ? AND " + MESSAGE_BOX + " & ? AND " + DATE_SENT + " < ?"; + long type = Types.getOutgoingEncryptedMessageType() | Types.GROUP_QUIT_BIT; + String[] args = new String[]{String.valueOf(threadId), String.valueOf(type), String.valueOf(quitTimeBarrier)}; + String orderBy = DATE_SENT + " DESC"; + String limit = "1"; + + try (Cursor cursor = db.query(TABLE_NAME, columns, query, args, null, null, orderBy, limit)) { + if (cursor.moveToFirst()) { + return cursor.getLong(cursor.getColumnIndex(DATE_SENT)); + } + } + + return -1; + } + public int getMessageCountForThread(long threadId) { SQLiteDatabase db = databaseHelper.getReadableDatabase(); Cursor cursor = null; @@ -533,14 +569,20 @@ public class MmsDatabase extends MessagingDatabase { database.beginTransaction(); try { - cursor = database.query(TABLE_NAME, new String[] {ID, RECIPIENT_ID, DATE_SENT, MESSAGE_BOX, EXPIRES_IN, EXPIRE_STARTED}, where, arguments, null, null, null); + cursor = database.query(TABLE_NAME, new String[] {ID, RECIPIENT_ID, DATE_SENT, MESSAGE_BOX, EXPIRES_IN, EXPIRE_STARTED, THREAD_ID}, where, arguments, null, null, null); while(cursor != null && cursor.moveToNext()) { - if (Types.isSecureType(cursor.getLong(3))) { - SyncMessageId syncMessageId = new SyncMessageId(RecipientId.from(cursor.getLong(1)), cursor.getLong(2)); - ExpirationInfo expirationInfo = new ExpirationInfo(cursor.getLong(0), cursor.getLong(4), cursor.getLong(5), true); + if (Types.isSecureType(cursor.getLong(cursor.getColumnIndex(MESSAGE_BOX)))) { + long threadId = cursor.getLong(cursor.getColumnIndex(THREAD_ID)); + RecipientId recipientId = RecipientId.from(cursor.getLong(cursor.getColumnIndex(RECIPIENT_ID))); + long dateSent = cursor.getLong(cursor.getColumnIndex(DATE_SENT)); + long messageId = cursor.getLong(cursor.getColumnIndex(ID)); + long expiresIn = cursor.getLong(cursor.getColumnIndex(EXPIRES_IN)); + long expireStarted = cursor.getLong(cursor.getColumnIndex(EXPIRE_STARTED)); + SyncMessageId syncMessageId = new SyncMessageId(recipientId, dateSent); + ExpirationInfo expirationInfo = new ExpirationInfo(messageId, expiresIn, expireStarted, true); - result.add(new MarkedMessageInfo(syncMessageId, expirationInfo)); + result.add(new MarkedMessageInfo(threadId, syncMessageId, expirationInfo)); } } 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 438de54343..95e8e0df89 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java @@ -22,6 +22,8 @@ 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; @@ -30,8 +32,10 @@ import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.recipients.Recipient; 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 { @@ -84,6 +88,32 @@ public class MmsSmsDatabase extends Database { super(context, databaseHelper); } + public @Nullable RecipientId getRecipientIdForLatestAdd(long threadId) { + long lastQuitChecked = System.currentTimeMillis(); + Pair pair; + + do { + pair = getRecipientIdForLatestAdd(threadId, lastQuitChecked); + if (pair.first() != null) { + return pair.first(); + } else { + lastQuitChecked = pair.second(); + } + + } while (pair.second() != -1L); + + return null; + } + + private @NonNull Pair getRecipientIdForLatestAdd(long threadId, long lastQuitChecked) { + MmsDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(context); + SmsDatabase smsDatabase = DatabaseFactory.getSmsDatabase(context); + long latestQuit = mmsDatabase.getLatestGroupQuitTimestamp(threadId, lastQuitChecked); + RecipientId id = smsDatabase.getOldestGroupUpdateSender(threadId, latestQuit); + + return new Pair<>(id, latestQuit); + } + public @Nullable MessageRecord getMessageFor(long timestamp, RecipientId author) { MmsSmsDatabase db = DatabaseFactory.getMmsSmsDatabase(context); @@ -166,6 +196,28 @@ public class MmsSmsDatabase extends Database { } } + public int getSecureConversationCount(long threadId) { + if (threadId == -1) { + return 0; + } + + int count = DatabaseFactory.getSmsDatabase(context).getSecureMessageCount(threadId); + count += DatabaseFactory.getMmsDatabase(context).getSecureMessageCount(threadId); + + return count; + } + + public int getOutgoingSecureConversationCount(long threadId) { + if (threadId == -1L) { + return 0; + } + + int count = DatabaseFactory.getSmsDatabase(context).getOutgoingSecureMessageCount(threadId); + count += DatabaseFactory.getMmsDatabase(context).getOutgoingSecureMessageCount(threadId); + + return count; + } + public int getConversationCount(long threadId) { int count = DatabaseFactory.getSmsDatabase(context).getMessageCountForThread(threadId); count += DatabaseFactory.getMmsDatabase(context).getMessageCountForThread(threadId); @@ -194,6 +246,13 @@ public class MmsSmsDatabase extends Database { return count; } + public long getThreadForMessageId(long messageId) { + long id = DatabaseFactory.getSmsDatabase(context).getThreadIdForMessage(messageId); + + if (id == -1) return DatabaseFactory.getMmsDatabase(context).getThreadIdForMessage(messageId); + else return id; + } + public void incrementDeliveryReceiptCount(SyncMessageId syncMessageId, long timestamp) { DatabaseFactory.getSmsDatabase(context).incrementReceiptCount(syncMessageId, true, false); DatabaseFactory.getMmsDatabase(context).incrementReceiptCount(syncMessageId, timestamp, true, false); 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 fa31ad9550..55b3d26bdd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -20,10 +20,12 @@ package org.thoughtcrime.securesms.database; import android.content.ContentValues; import android.content.Context; import android.database.Cursor; -import androidx.annotation.NonNull; import android.text.TextUtils; import android.util.Pair; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + import com.annimon.stream.Stream; import net.sqlcipher.database.SQLiteDatabase; @@ -45,7 +47,6 @@ import org.thoughtcrime.securesms.sms.IncomingTextMessage; import org.thoughtcrime.securesms.sms.OutgoingTextMessage; import org.thoughtcrime.securesms.util.JsonUtils; import org.thoughtcrime.securesms.util.TextSecurePreferences; -import org.thoughtcrime.securesms.util.Util; import org.whispersystems.libsignal.util.guava.Optional; import java.io.IOException; @@ -55,7 +56,6 @@ import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Set; -import java.util.concurrent.TimeUnit; /** * Database for storage of SMS messages. @@ -162,6 +162,24 @@ public class SmsDatabase extends MessagingDatabase { notifyConversationListeners(threadId); } + public @Nullable RecipientId getOldestGroupUpdateSender(long threadId, long minimumDateReceived) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + + String[] columns = new String[]{RECIPIENT_ID}; + String query = THREAD_ID + " = ? AND " + TYPE + " & ? AND " + DATE_RECEIVED + " >= ?"; + long type = Types.SECURE_MESSAGE_BIT | Types.PUSH_MESSAGE_BIT | Types.GROUP_UPDATE_BIT | Types.BASE_INBOX_TYPE; + String[] args = new String[]{String.valueOf(threadId), String.valueOf(type), String.valueOf(minimumDateReceived)}; + String limit = "1"; + + try (Cursor cursor = db.query(TABLE_NAME, columns, query, args, null, null, limit)) { + if (cursor.moveToFirst()) { + return RecipientId.from(cursor.getLong(cursor.getColumnIndex(RECIPIENT_ID))); + } + } + + return null; + } + public long getThreadIdForMessage(long id) { String sql = "SELECT " + THREAD_ID + " FROM " + TABLE_NAME + " WHERE " + ID + " = ?"; String[] sqlArgs = new String[] {id+""}; @@ -446,14 +464,20 @@ public class SmsDatabase extends MessagingDatabase { database.beginTransaction(); try { - cursor = database.query(TABLE_NAME, new String[] {ID, RECIPIENT_ID, DATE_SENT, TYPE, EXPIRES_IN, EXPIRE_STARTED}, where, arguments, null, null, null); + cursor = database.query(TABLE_NAME, new String[] {ID, RECIPIENT_ID, DATE_SENT, TYPE, EXPIRES_IN, EXPIRE_STARTED, THREAD_ID}, where, arguments, null, null, null); while (cursor != null && cursor.moveToNext()) { - if (Types.isSecureType(cursor.getLong(3))) { - SyncMessageId syncMessageId = new SyncMessageId(RecipientId.from(cursor.getLong(1)), cursor.getLong(2)); - ExpirationInfo expirationInfo = new ExpirationInfo(cursor.getLong(0), cursor.getLong(4), cursor.getLong(5), false); + if (Types.isSecureType(cursor.getLong(cursor.getColumnIndex(TYPE)))) { + long threadId = cursor.getLong(cursor.getColumnIndex(THREAD_ID)); + RecipientId recipientId = RecipientId.from(cursor.getLong(cursor.getColumnIndex(RECIPIENT_ID))); + long dateSent = cursor.getLong(cursor.getColumnIndex(DATE_SENT)); + long messageId = cursor.getLong(cursor.getColumnIndex(ID)); + long expiresIn = cursor.getLong(cursor.getColumnIndex(EXPIRES_IN)); + long expireStarted = cursor.getLong(cursor.getColumnIndex(EXPIRE_STARTED)); + SyncMessageId syncMessageId = new SyncMessageId(recipientId, dateSent); + ExpirationInfo expirationInfo = new ExpirationInfo(messageId, expiresIn, expireStarted, false); - results.add(new MarkedMessageInfo(syncMessageId, expirationInfo)); + results.add(new MarkedMessageInfo(threadId, syncMessageId, expirationInfo)); } } 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 336cd327e4..586a675537 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -44,6 +44,7 @@ import org.thoughtcrime.securesms.mms.Slide; import org.thoughtcrime.securesms.mms.SlideDeck; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; import org.thoughtcrime.securesms.util.JsonUtils; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.Util; @@ -704,13 +705,30 @@ public class ThreadDatabase extends Database { } private @Nullable Extra getExtrasFor(MessageRecord record) { + boolean messageRequestAccepted = RecipientUtil.isThreadMessageRequestAccepted(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()); + + if (recipientId != null) { + return Extra.forGroupMessageRequest(recipientId); + } + } + + return Extra.forMessageRequest(); + } + if (record.isMms() && ((MmsMessageRecord) record).isViewOnce()) { - return Extra.forRevealableMessage(); + return Extra.forRevealable(); } else if (record.isMms() && ((MmsMessageRecord) record).getSlideDeck().getStickerSlide() != null) { return Extra.forSticker(); } else if (record.isMms() && ((MmsMessageRecord) record).getSlideDeck().getSlides().size() > 1) { return Extra.forAlbum(); } + return null; } @@ -829,28 +847,41 @@ public class ThreadDatabase extends Database { @JsonProperty private final boolean isRevealable; @JsonProperty private final boolean isSticker; @JsonProperty private final boolean isAlbum; + @JsonProperty private final boolean isMessageRequestAccepted; + @JsonProperty private final String groupAddedBy; public Extra(@JsonProperty("isRevealable") boolean isRevealable, @JsonProperty("isSticker") boolean isSticker, - @JsonProperty("isAlbum") boolean isAlbum) + @JsonProperty("isAlbum") boolean isAlbum, + @JsonProperty("isMessageRequestAccepted") boolean isMessageRequestAccepted, + @JsonProperty("groupAddedBy") String groupAddedBy) { - this.isRevealable = isRevealable; - this.isSticker = isSticker; - this.isAlbum = isAlbum; + this.isRevealable = isRevealable; + this.isSticker = isSticker; + this.isAlbum = isAlbum; + this.isMessageRequestAccepted = isMessageRequestAccepted; + this.groupAddedBy = groupAddedBy; } - public static @NonNull Extra forRevealableMessage() { - return new Extra(true, false, false); + public static @NonNull Extra forRevealable() { + return new Extra(true, false, false, true, null); } public static @NonNull Extra forSticker() { - return new Extra(false, true, false); + return new Extra(false, true, false, true, null); } public static @NonNull Extra forAlbum() { - return new Extra(false, false, true); + return new Extra(false, false, true, true, null); } + public static @NonNull Extra forMessageRequest() { + return new Extra(false, false, false, false, null); + } + + public static @NonNull Extra forGroupMessageRequest(RecipientId recipientId) { + return new Extra(false, false, false, false, recipientId.serialize()); + } public boolean isRevealable() { return isRevealable; @@ -863,5 +894,13 @@ public class ThreadDatabase extends Database { public boolean isAlbum() { return isAlbum; } + + public boolean isMessageRequestAccepted() { + return isMessageRequestAccepted; + } + + public @Nullable String getGroupAddedBy() { + return groupAddedBy; + } } } 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 fe6a2af0e7..2806e9c459 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 @@ -4,6 +4,7 @@ import android.content.Context; import android.database.Cursor; import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.recipients.RecipientUtil; import org.thoughtcrime.securesms.util.AbstractCursorLoader; import org.whispersystems.libsignal.util.Pair; @@ -13,6 +14,7 @@ public class ConversationLoader extends AbstractCursorLoader { private int limit; private long lastSeen; private boolean hasSent; + private boolean isMessageRequestAccepted; public ConversationLoader(Context context, long threadId, int offset, int limit, long lastSeen) { super(context); @@ -43,6 +45,10 @@ public class ConversationLoader extends AbstractCursorLoader { return hasSent; } + public boolean isMessageRequestAccepted() { + return isMessageRequestAccepted; + } + @Override public Cursor getCursor() { Pair lastSeenAndHasSent = DatabaseFactory.getThreadDatabase(context).getLastSeenAndHasSent(threadId); @@ -53,6 +59,8 @@ public class ConversationLoader extends AbstractCursorLoader { this.lastSeen = lastSeenAndHasSent.first(); } + this.isMessageRequestAccepted = RecipientUtil.isThreadMessageRequestAccepted(context, threadId); + return DatabaseFactory.getMmsSmsDatabase(context).getConversation(threadId, offset, limit); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java index 905b51becb..cecb13750f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java @@ -31,6 +31,7 @@ import org.thoughtcrime.securesms.database.MmsSmsColumns; import org.thoughtcrime.securesms.database.SmsDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase.Extra; import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.util.ExpirationUtil; import org.thoughtcrime.securesms.util.MediaUtil; @@ -77,7 +78,11 @@ public class ThreadRecord extends DisplayRecord { @Override public SpannableString getDisplayBody(@NonNull Context context) { - if (isGroupUpdate()) { + if (getGroupAddedBy() != null) { + return emphasisAdded(context.getString(R.string.ThreadRecord_s_added_you_to_the_group, Recipient.live(getGroupAddedBy()).get().getDisplayName(context))); + } else if (!isMessageRequestAccepted()) { + return emphasisAdded(context.getString(R.string.ThreadRecord_message_request)); + } else if (isGroupUpdate()) { return emphasisAdded(context.getString(R.string.ThreadRecord_group_updated)); } else if (isGroupQuit()) { return emphasisAdded(context.getString(R.string.ThreadRecord_left_the_group)); @@ -181,4 +186,14 @@ public class ThreadRecord extends DisplayRecord { public long getLastSeen() { return lastSeen; } + + public @Nullable RecipientId getGroupAddedBy() { + if (extra != null && extra.getGroupAddedBy() != null) return RecipientId.from(extra.getGroupAddedBy()); + else return null; + } + + public boolean isMessageRequestAccepted() { + if (extra != null) return extra.isMessageRequestAccepted(); + else return true; + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/Data.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/Data.java index acb573c4ca..c7923ed6d4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/Data.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/Data.java @@ -208,6 +208,10 @@ public class Data { } } + public Builder buildUpon() { + return new Builder(this); + } + public static class Builder { @@ -224,6 +228,23 @@ public class Data { private final Map booleans = new HashMap<>(); private final Map booleanArrays = new HashMap<>(); + public Builder() { } + + private Builder(@NonNull Data oldData) { + strings.putAll(oldData.strings); + stringArrays.putAll(oldData.stringArrays); + integers.putAll(oldData.integers); + integerArrays.putAll(oldData.integerArrays); + longs.putAll(oldData.longs); + longArrays.putAll(oldData.longArrays); + floats.putAll(oldData.floats); + floatArrays.putAll(oldData.floatArrays); + doubles.putAll(oldData.doubles); + doubleArrays.putAll(oldData.doubleArrays); + booleans.putAll(oldData.booleans); + booleanArrays.putAll(oldData.booleanArrays); + } + public Builder putString(@NonNull String key, @Nullable String value) { strings.put(key, value); return this; diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobManager.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobManager.java index 9155df7822..8425e635fc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobManager.java @@ -34,7 +34,7 @@ public class JobManager implements ConstraintObserver.Notifier { private static final String TAG = JobManager.class.getSimpleName(); - public static final int CURRENT_VERSION = 4; + public static final int CURRENT_VERSION = 5; private final Application application; private final Configuration configuration; diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/migrations/SendReadReceiptsJobMigration.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/migrations/SendReadReceiptsJobMigration.java new file mode 100644 index 0000000000..6938af64a8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/migrations/SendReadReceiptsJobMigration.java @@ -0,0 +1,55 @@ +package org.thoughtcrime.securesms.jobmanager.migrations; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MmsSmsDatabase; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.JobMigration; + +import java.util.SortedSet; +import java.util.TreeSet; + +public class SendReadReceiptsJobMigration extends JobMigration { + + private final MmsSmsDatabase mmsSmsDatabase; + + public SendReadReceiptsJobMigration(@NonNull MmsSmsDatabase mmsSmsDatabase) { + super(5); + this.mmsSmsDatabase = mmsSmsDatabase; + } + + @Override + protected @NonNull JobData migrate(@NonNull JobData jobData) { + if ("SendReadReceiptJob".equals(jobData.getFactoryKey())) { + return migrateSendReadReceiptJob(mmsSmsDatabase, jobData); + } + return jobData; + } + + private static @NonNull JobData migrateSendReadReceiptJob(@NonNull MmsSmsDatabase mmsSmsDatabase, @NonNull JobData jobData) { + Data data = jobData.getData(); + + if (!data.hasLong("thread")) { + long[] messageIds = jobData.getData().getLongArray("message_ids"); + SortedSet threadIds = new TreeSet<>(); + + for (long id : messageIds) { + long threadForMessageId = mmsSmsDatabase.getThreadForMessageId(id); + if (id != -1) { + threadIds.add(threadForMessageId); + } + } + + if (threadIds.size() != 1) { + return new JobData("FailingJob", null, new Data.Builder().build()); + } else { + return jobData.withData(data.buildUpon().putLong("thread", threadIds.first()).build()); + } + + } else { + return jobData; + } + } + +} 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 ae289774f4..abc368f1b1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -4,6 +4,7 @@ import android.app.Application; import androidx.annotation.NonNull; +import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.jobmanager.Constraint; import org.thoughtcrime.securesms.jobmanager.ConstraintObserver; import org.thoughtcrime.securesms.jobmanager.Job; @@ -18,6 +19,7 @@ import org.thoughtcrime.securesms.jobmanager.impl.SqlCipherMigrationConstraintOb import org.thoughtcrime.securesms.jobmanager.migrations.RecipientIdFollowUpJobMigration; import org.thoughtcrime.securesms.jobmanager.migrations.RecipientIdFollowUpJobMigration2; import org.thoughtcrime.securesms.jobmanager.migrations.RecipientIdJobMigration; +import org.thoughtcrime.securesms.jobmanager.migrations.SendReadReceiptsJobMigration; import org.thoughtcrime.securesms.migrations.Argon2TestMigrationJob; import org.thoughtcrime.securesms.migrations.AvatarMigrationJob; import org.thoughtcrime.securesms.migrations.CachedAttachmentsMigrationJob; @@ -140,6 +142,7 @@ public final class JobManagerFactories { public static List getJobMigrations(@NonNull Application application) { return Arrays.asList(new RecipientIdJobMigration(application), new RecipientIdFollowUpJobMigration(), - new RecipientIdFollowUpJobMigration2()); + new RecipientIdFollowUpJobMigration2(), + new SendReadReceiptsJobMigration(DatabaseFactory.getMmsSmsDatabase(application))); } } 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 69ba9601ac..5c48f9badd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java @@ -28,8 +28,10 @@ import org.thoughtcrime.securesms.mms.OutgoingGroupMediaMessage; import org.thoughtcrime.securesms.mms.OutgoingMediaMessage; 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.transport.UndeliverableMessageException; +import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.GroupUtil; import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.SignalServiceMessageSender; @@ -151,6 +153,10 @@ public class PushGroupSendJob extends PushSendJob { try { log(TAG, "Sending message: " + messageId); + if (FeatureFlags.messageRequests() && !message.getRecipient().resolve().isProfileSharing() && !database.isGroupQuitMessage(messageId)) { + RecipientUtil.shareProfileIfFirstSecureMessage(context, message.getRecipient()); + } + List target; if (filterRecipient != null) target = Collections.singletonList(Recipient.resolved(filterRecipient).getId()); 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 8e86e9d469..03773b96bb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushMediaSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushMediaSendJob.java @@ -23,6 +23,7 @@ import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.mms.MmsException; import org.thoughtcrime.securesms.mms.OutgoingMediaMessage; import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientUtil; import org.thoughtcrime.securesms.service.ExpiringMessageManager; import org.thoughtcrime.securesms.transport.InsecureFallbackApprovalException; import org.thoughtcrime.securesms.transport.RetryLaterException; @@ -117,6 +118,8 @@ public class PushMediaSendJob extends PushSendJob { try { log(TAG, "Sending message: " + messageId); + RecipientUtil.shareProfileIfFirstSecureMessage(context, message.getRecipient()); + Recipient recipient = message.getRecipient().resolve(); byte[] profileKey = recipient.getProfileKey(); UnidentifiedAccessMode accessMode = recipient.getUnidentifiedAccessMode(); 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 b604d5021b..8a4018ddc4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushTextSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushTextSendJob.java @@ -16,6 +16,7 @@ import org.thoughtcrime.securesms.jobmanager.Data; import org.thoughtcrime.securesms.jobmanager.Job; import org.thoughtcrime.securesms.notifications.MessageNotifier; import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientUtil; import org.thoughtcrime.securesms.service.ExpiringMessageManager; import org.thoughtcrime.securesms.transport.InsecureFallbackApprovalException; import org.thoughtcrime.securesms.transport.RetryLaterException; @@ -80,6 +81,8 @@ public class PushTextSendJob extends PushSendJob { try { log(TAG, "Sending message: " + messageId); + RecipientUtil.shareProfileIfFirstSecureMessage(context, record.getRecipient()); + Recipient recipient = record.getRecipient().resolve(); byte[] profileKey = recipient.getProfileKey(); UnidentifiedAccessMode accessMode = recipient.getUnidentifiedAccessMode(); 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 384a117f8e..55f44e11c3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/SendReadReceiptJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/SendReadReceiptJob.java @@ -32,33 +32,38 @@ public class SendReadReceiptJob extends BaseJob { private static final String TAG = SendReadReceiptJob.class.getSimpleName(); + private static final String KEY_THREAD = "thread"; private static final String KEY_ADDRESS = "address"; private static final String KEY_RECIPIENT = "recipient"; private static final String KEY_MESSAGE_IDS = "message_ids"; private static final String KEY_TIMESTAMP = "timestamp"; + private long threadId; private RecipientId recipientId; private List messageIds; private long timestamp; - public SendReadReceiptJob(@NonNull RecipientId recipientId, List messageIds) { + public SendReadReceiptJob(long threadId, @NonNull RecipientId recipientId, List messageIds) { this(new Job.Parameters.Builder() .addConstraint(NetworkConstraint.KEY) .setLifespan(TimeUnit.DAYS.toMillis(1)) .setMaxAttempts(Parameters.UNLIMITED) .build(), + threadId, recipientId, messageIds, System.currentTimeMillis()); } private SendReadReceiptJob(@NonNull Job.Parameters parameters, + long threadId, @NonNull RecipientId recipientId, @NonNull List messageIds, long timestamp) { super(parameters); + this.threadId = threadId; this.recipientId = recipientId; this.messageIds = messageIds; this.timestamp = timestamp; @@ -74,6 +79,7 @@ public class SendReadReceiptJob extends BaseJob { return new Data.Builder().putString(KEY_RECIPIENT, recipientId.serialize()) .putLongArray(KEY_MESSAGE_IDS, ids) .putLong(KEY_TIMESTAMP, timestamp) + .putLong(KEY_THREAD, threadId) .build(); } @@ -86,12 +92,12 @@ public class SendReadReceiptJob extends BaseJob { public void onRun() throws IOException, UntrustedIdentityException { if (!TextSecurePreferences.isReadReceiptsEnabled(context) || messageIds.isEmpty()) return; - Recipient recipient = Recipient.resolved(recipientId); - if (!RecipientUtil.isRecipientMessageRequestAccepted(context, recipient)) { + if (!RecipientUtil.isThreadMessageRequestAccepted(context, threadId)) { Log.w(TAG, "Refusing to send receipts to untrusted recipient"); return; } + Recipient recipient = Recipient.resolved(recipientId); SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); SignalServiceAddress remoteAddress = RecipientUtil.toSignalServiceAddress(context, recipient); SignalServiceReceiptMessage receiptMessage = new SignalServiceReceiptMessage(SignalServiceReceiptMessage.Type.READ, messageIds, timestamp); @@ -127,12 +133,13 @@ public class SendReadReceiptJob extends BaseJob { List messageIds = new ArrayList<>(ids.length); RecipientId recipientId = data.hasString(KEY_RECIPIENT) ? RecipientId.from(data.getString(KEY_RECIPIENT)) : Recipient.external(application, data.getString(KEY_ADDRESS)).getId(); + long threadId = data.getLong(KEY_THREAD); for (long id : ids) { messageIds.add(id); } - return new SendReadReceiptJob(parameters, recipientId, messageIds, timestamp); + return new SendReadReceiptJob(parameters, threadId, recipientId, messageIds, timestamp); } } } 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 9c1ec7585c..4d8044fc8d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java @@ -24,6 +24,7 @@ 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/megaphone/Megaphone.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphone.java index 33503042c6..86927f3822 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphone.java +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphone.java @@ -156,7 +156,7 @@ public class Megaphone { } enum Style { - REACTIONS, BASIC, FULLSCREEN + REACTIONS, BASIC, FULLSCREEN, POPUP } public interface EventListener { diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneRepository.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneRepository.java index 9897f84616..7bb277daa9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneRepository.java @@ -50,6 +50,7 @@ public class MegaphoneRepository { public void onFirstEverAppLaunch() { executor.execute(() -> { database.markFinished(Event.REACTIONS); + database.markFinished(Event.MESSAGE_REQUESTS); resetDatabaseCache(); }); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneViewBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneViewBuilder.java index fa0c65a843..77d0fb9d5d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneViewBuilder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneViewBuilder.java @@ -21,6 +21,8 @@ public class MegaphoneViewBuilder { return null; case REACTIONS: return buildReactionsMegaphone(context, megaphone, listener); + case POPUP: + return buildPopupMegaphone(context, megaphone, listener); default: throw new IllegalArgumentException("No view implemented for style!"); } @@ -43,4 +45,13 @@ public class MegaphoneViewBuilder { view.present(megaphone, listener); return view; } + + private static @NonNull View buildPopupMegaphone(@NonNull Context context, + @NonNull Megaphone megaphone, + @NonNull MegaphoneActionController listener) + { + PopupMegaphoneView view = new PopupMegaphoneView(context); + view.present(megaphone, listener); + return view; + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java index 6d23c49cf4..8b2e5c5ebf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java @@ -9,6 +9,7 @@ import androidx.annotation.Nullable; import com.annimon.stream.Stream; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.conversationlist.ConversationListFragment; import org.thoughtcrime.securesms.database.model.MegaphoneRecord; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; @@ -19,6 +20,7 @@ import org.thoughtcrime.securesms.lock.v2.CreateKbsPinActivity; import org.thoughtcrime.securesms.lock.v2.KbsMigrationActivity; import org.thoughtcrime.securesms.lock.v2.PinUtil; import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.messagerequests.MessageRequestMegaphoneActivity; import org.thoughtcrime.securesms.profiles.ProfileName; import org.thoughtcrime.securesms.profiles.edit.EditProfileActivity; import org.thoughtcrime.securesms.util.FeatureFlags; @@ -49,7 +51,8 @@ public final class Megaphones { private static final MegaphoneSchedule ALWAYS = new ForeverSchedule(true); private static final MegaphoneSchedule NEVER = new ForeverSchedule(false); - private static final MegaphoneSchedule EVERY_TWO_DAYS = new RecurringSchedule(TimeUnit.DAYS.toMillis(2)); + + static final MegaphoneSchedule EVERY_TWO_DAYS = new RecurringSchedule(TimeUnit.DAYS.toMillis(2)); private Megaphones() {} @@ -90,8 +93,9 @@ public final class Megaphones { return new LinkedHashMap() {{ put(Event.REACTIONS, ALWAYS); put(Event.PINS_FOR_ALL, new PinsForAllSchedule()); - put(Event.PROFILE_NAMES_FOR_ALL, FeatureFlags.profileNamesMegaphoneEnabled() ? EVERY_TWO_DAYS : NEVER); + put(Event.PROFILE_NAMES_FOR_ALL, FeatureFlags.profileNamesMegaphone() ? EVERY_TWO_DAYS : NEVER); put(Event.PIN_REMINDER, new SignalPinReminderSchedule()); + put(Event.MESSAGE_REQUESTS, shouldShowMessageRequestsMegaphone() ? ALWAYS : NEVER); }}; } @@ -105,6 +109,8 @@ public final class Megaphones { return buildPinReminderMegaphone(context); case PROFILE_NAMES_FOR_ALL: return buildProfileNamesMegaphone(context); + case MESSAGE_REQUESTS: + return buildMessageRequestsMegaphone(context); default: throw new IllegalArgumentException("Event not handled!"); } @@ -195,6 +201,10 @@ public final class Megaphones { } private static @NonNull Megaphone buildProfileNamesMegaphone(@NonNull Context context) { + short requestCode = TextSecurePreferences.getProfileName(context) != ProfileName.EMPTY + ? ConversationListFragment.PROFILE_NAMES_REQUEST_CODE_CONFIRM_NAME + : ConversationListFragment.PROFILE_NAMES_REQUEST_CODE_CREATE_NAME; + Megaphone.Builder builder = new Megaphone.Builder(Event.PROFILE_NAMES_FOR_ALL, Megaphone.Style.BASIC) .enableSnooze(null) .setImage(R.drawable.profile_megaphone); @@ -204,7 +214,7 @@ public final class Megaphones { .setBody(R.string.ProfileNamesMegaphone__this_will_be_displayed_when_you_start) .setActionButton(R.string.ProfileNamesMegaphone__add_profile_name, (megaphone, listener) -> { listener.onMegaphoneSnooze(Event.PROFILE_NAMES_FOR_ALL); - listener.onMegaphoneNavigationRequested(new Intent(context, EditProfileActivity.class)); + listener.onMegaphoneNavigationRequested(new Intent(context, EditProfileActivity.class), requestCode); }) .build(); } else { @@ -212,17 +222,34 @@ public final class Megaphones { .setBody(R.string.ProfileNamesMegaphone__your_profile_can_now_include) .setActionButton(R.string.ProfileNamesMegaphone__confirm_name, (megaphone, listener) -> { listener.onMegaphoneCompleted(Event.PROFILE_NAMES_FOR_ALL); - listener.onMegaphoneNavigationRequested(new Intent(context, EditProfileActivity.class)); + listener.onMegaphoneNavigationRequested(new Intent(context, EditProfileActivity.class), requestCode); }) .build(); } } + private static @NonNull Megaphone buildMessageRequestsMegaphone(@NonNull Context context) { + return new Megaphone.Builder(Event.MESSAGE_REQUESTS, Megaphone.Style.FULLSCREEN) + .disableSnooze() + .setMandatory(true) + .setOnVisibleListener(((megaphone, listener) -> { + listener.onMegaphoneNavigationRequested(new Intent(context, MessageRequestMegaphoneActivity.class), + ConversationListFragment.MESSAGE_REQUESTS_REQUEST_CODE_CREATE_NAME); + })) + .build(); + } + + private static boolean shouldShowMessageRequestsMegaphone() { + boolean userHasAProfileName = TextSecurePreferences.getProfileName(ApplicationDependencies.getApplication()) != ProfileName.EMPTY; + return FeatureFlags.messageRequests() && !userHasAProfileName; + } + public enum Event { REACTIONS("reactions"), PINS_FOR_ALL("pins_for_all"), PIN_REMINDER("pin_reminder"), - PROFILE_NAMES_FOR_ALL("profile_names"); + PROFILE_NAMES_FOR_ALL("profile_names"), + MESSAGE_REQUESTS("message_requests"); private final String key; diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/PopupMegaphoneView.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/PopupMegaphoneView.java new file mode 100644 index 0000000000..9ad9febfb1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/PopupMegaphoneView.java @@ -0,0 +1,86 @@ +package org.thoughtcrime.securesms.megaphone; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; +import android.widget.Button; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.R; + +public class PopupMegaphoneView extends FrameLayout { + + private ImageView image; + private TextView titleText; + private TextView bodyText; + private View xButton; + + private Megaphone megaphone; + private MegaphoneActionController megaphoneListener; + + public PopupMegaphoneView(@NonNull Context context) { + super(context); + init(context); + } + + public PopupMegaphoneView(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + init(context); + } + + private void init(@NonNull Context context) { + inflate(context, R.layout.popup_megaphone_view, this); + + this.image = findViewById(R.id.popup_megaphone_image); + this.titleText = findViewById(R.id.popup_megaphone_title); + this.bodyText = findViewById(R.id.popup_megaphone_body); + this.xButton = findViewById(R.id.popup_x); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + + if (megaphone != null && megaphoneListener != null && megaphone.getOnVisibleListener() != null) { + megaphone.getOnVisibleListener().onEvent(megaphone, megaphoneListener); + } + } + + public void present(@NonNull Megaphone megaphone, @NonNull MegaphoneActionController megaphoneListener) { + this.megaphone = megaphone; + this.megaphoneListener = megaphoneListener; + + if (megaphone.getImage() != 0) { + image.setVisibility(VISIBLE); + image.setImageResource(megaphone.getImage()); + } else { + image.setVisibility(GONE); + } + + if (megaphone.getTitle() != 0) { + titleText.setVisibility(VISIBLE); + titleText.setText(megaphone.getTitle()); + } else { + titleText.setVisibility(GONE); + } + + if (megaphone.getBody() != 0) { + bodyText.setVisibility(VISIBLE); + bodyText.setText(megaphone.getBody()); + } else { + bodyText.setVisibility(GONE); + } + + if (megaphone.hasButton()) { + xButton.setOnClickListener(v -> megaphone.getButtonClickListener().onEvent(megaphone, megaphoneListener)); + } else { + xButton.setOnClickListener(v -> megaphoneListener.onMegaphoneCompleted(megaphone.getEvent())); + } + + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestFragment.java b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestFragment.java deleted file mode 100644 index 45fa1b179f..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestFragment.java +++ /dev/null @@ -1,172 +0,0 @@ -package org.thoughtcrime.securesms.messagerequests; - -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.FrameLayout; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.text.HtmlCompat; -import androidx.fragment.app.Fragment; -import androidx.lifecycle.ViewModelProviders; - -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.components.AvatarImageView; -import org.thoughtcrime.securesms.conversation.ConversationItem; -import org.thoughtcrime.securesms.database.model.MessageRecord; -import org.thoughtcrime.securesms.mms.GlideApp; -import org.thoughtcrime.securesms.recipients.Recipient; -import org.whispersystems.libsignal.util.guava.Optional; - -import java.util.Collections; -import java.util.List; -import java.util.Locale; - -public class MessageRequestFragment extends Fragment { - - private AvatarImageView contactAvatar; - private TextView contactTitle; - private TextView contactSubtitle; - private TextView contactDescription; - private FrameLayout messageView; - private TextView question; - private Button accept; - private Button block; - private Button delete; - private ConversationItem conversationItem; - - private MessageRequestFragmentViewModel viewModel; - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, - @Nullable ViewGroup container, - @Nullable Bundle savedInstanceState) - { - return inflater.inflate(R.layout.message_request_fragment, container, false); - } - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - contactAvatar = view.findViewById(R.id.message_request_avatar); - contactTitle = view.findViewById(R.id.message_request_title); - contactSubtitle = view.findViewById(R.id.message_request_subtitle); - contactDescription = view.findViewById(R.id.message_request_description); - messageView = view.findViewById(R.id.message_request_message); - question = view.findViewById(R.id.message_request_question); - accept = view.findViewById(R.id.message_request_accept); - block = view.findViewById(R.id.message_request_block); - delete = view.findViewById(R.id.message_request_delete); - - initializeViewModel(); - initializeBottomViewListeners(); - } - - private void initializeViewModel() { - viewModel = ViewModelProviders.of(requireActivity()).get(MessageRequestFragmentViewModel.class); - viewModel.getState().observe(getViewLifecycleOwner(), state -> { - if (state.messageRecord == null || state.recipient == null) return; - - presentConversationItemTo(state.messageRecord, state.recipient); - presentMessageRequestBottomViewTo(state.recipient); - presentMessageRequestProfileViewTo(state.recipient, state.groups, state.memberCount); - }); - } - - private void presentConversationItemTo(@NonNull MessageRecord messageRecord, @NonNull Recipient recipient) { - if (messageRecord.isGroupAction()) { - if (conversationItem != null) { - messageView.removeAllViews(); - } - return; - } - - if (conversationItem == null) { - conversationItem = (ConversationItem) LayoutInflater.from(requireActivity()).inflate(R.layout.conversation_item_received, messageView, false); - } - - conversationItem.bind(messageRecord, - Optional.absent(), - Optional.absent(), - GlideApp.with(this), - Locale.getDefault(), - Collections.emptySet(), - recipient, - null, - false); - - if (messageView.getChildCount() == 0 || messageView.getChildAt(0) != conversationItem) { - messageView.removeAllViews(); - messageView.addView(conversationItem); - } - } - - private void presentMessageRequestProfileViewTo(@Nullable Recipient recipient, @Nullable List groups, int memberCount) { - if (recipient != null) { - contactAvatar.setAvatar(GlideApp.with(this), recipient, false); - - String title = recipient.getDisplayName(requireContext()); - contactTitle.setText(title); - - if (recipient.isGroup()) { - contactSubtitle.setText(getString(R.string.MessageRequestProfileView_members, memberCount)); - } else { - String subtitle = recipient.getUsername().or(recipient.getE164()).orNull(); - - if (subtitle == null || subtitle.equals(title)) { - contactSubtitle.setVisibility(View.GONE); - } else { - contactSubtitle.setText(subtitle); - } - } - } - - if (groups == null || groups.isEmpty()) { - contactDescription.setVisibility(View.GONE); - } else { - final String description; - - switch (groups.size()) { - case 1: - description = getString(R.string.MessageRequestProfileView_member_of_one_group, bold(groups.get(0))); - break; - case 2: - description = getString(R.string.MessageRequestProfileView_member_of_two_groups, bold(groups.get(0)), bold(groups.get(1))); - break; - case 3: - description = getString(R.string.MessageRequestProfileView_member_of_many_groups, bold(groups.get(0)), bold(groups.get(1)), bold(groups.get(2))); - break; - default: - int others = groups.size() - 2; - description = getString(R.string.MessageRequestProfileView_member_of_many_groups, - bold(groups.get(0)), - bold(groups.get(1)), - getResources().getQuantityString(R.plurals.MessageRequestProfileView_member_of_others, others, others)); - } - - contactDescription.setText(HtmlCompat.fromHtml(description, 0)); - contactDescription.setVisibility(View.VISIBLE); - } - } - - private @NonNull String bold(@NonNull String target) { - return "" + target + ""; - } - - private void presentMessageRequestBottomViewTo(@Nullable Recipient recipient) { - if (recipient == null) return; - - question.setText(HtmlCompat.fromHtml(getString(R.string.MessageRequestBottomView_do_you_want_to_let, bold(recipient.getDisplayName(requireContext()))), 0)); - } - - private void initializeBottomViewListeners() { - accept.setOnClickListener(v -> viewModel.accept()); - delete.setOnClickListener(v -> viewModel.delete()); - block.setOnClickListener(v -> viewModel.block()); - } - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestFragmentState.java b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestFragmentState.java deleted file mode 100644 index 7ec1e72b3a..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestFragmentState.java +++ /dev/null @@ -1,90 +0,0 @@ -package org.thoughtcrime.securesms.messagerequests; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.thoughtcrime.securesms.database.model.MessageRecord; -import org.thoughtcrime.securesms.recipients.Recipient; - -import java.util.List; - -public class MessageRequestFragmentState { - - public enum MessageRequestState { - LOADING, - PENDING, - BLOCKED, - DELETED, - ACCEPTED - } - - public final @NonNull MessageRequestState messageRequestState; - public final @Nullable MessageRecord messageRecord; - public final @Nullable Recipient recipient; - public final @Nullable List groups; - public final int memberCount; - - - public MessageRequestFragmentState(@NonNull MessageRequestState messageRequestState, - @Nullable MessageRecord messageRecord, - @Nullable Recipient recipient, - @Nullable List groups, - int memberCount) - { - this.messageRequestState = messageRequestState; - this.messageRecord = messageRecord; - this.recipient = recipient; - this.groups = groups; - this.memberCount = memberCount; - } - - public @NonNull MessageRequestFragmentState updateMessageRequestState(@NonNull MessageRequestState messageRequestState) { - return new MessageRequestFragmentState(messageRequestState, - this.messageRecord, - this.recipient, - this.groups, - this.memberCount); - } - - public @NonNull MessageRequestFragmentState updateMessageRecord(@NonNull MessageRecord messageRecord) { - return new MessageRequestFragmentState(this.messageRequestState, - messageRecord, - this.recipient, - this.groups, - this.memberCount); - } - - public @NonNull MessageRequestFragmentState updateRecipient(@NonNull Recipient recipient) { - return new MessageRequestFragmentState(this.messageRequestState, - this.messageRecord, - recipient, - this.groups, - this.memberCount); - } - - public @NonNull MessageRequestFragmentState updateGroups(@NonNull List groups) { - return new MessageRequestFragmentState(this.messageRequestState, - this.messageRecord, - this.recipient, - groups, - this.memberCount); - } - - public @NonNull MessageRequestFragmentState updateMemberCount(int memberCount) { - return new MessageRequestFragmentState(this.messageRequestState, - this.messageRecord, - this.recipient, - this.groups, - memberCount); - } - - @Override - public @NonNull String toString() { - return "MessageRequestFragmentState: [" + - messageRequestState.name() + "] [" + - messageRecord + "] [" + - recipient + "] [" + - groups + "] [" + - memberCount + "]"; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestFragmentViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestFragmentViewModel.java deleted file mode 100644 index 67c59cd580..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestFragmentViewModel.java +++ /dev/null @@ -1,139 +0,0 @@ -package org.thoughtcrime.securesms.messagerequests; - -import android.content.Context; -import android.util.Log; - -import androidx.annotation.MainThread; -import androidx.annotation.NonNull; -import androidx.arch.core.util.Function; -import androidx.lifecycle.LiveData; -import androidx.lifecycle.MutableLiveData; -import androidx.lifecycle.ViewModel; -import androidx.lifecycle.ViewModelProvider; - -import org.thoughtcrime.securesms.recipients.RecipientForeverObserver; -import org.thoughtcrime.securesms.recipients.RecipientId; - -public class MessageRequestFragmentViewModel extends ViewModel { - - private static final String TAG = MessageRequestFragmentViewModel.class.getSimpleName(); - - private final MutableLiveData internalState = new MutableLiveData<>(); - - private final MessageRequestFragmentRepository repository; - - @SuppressWarnings("CodeBlock2Expr") - private final RecipientForeverObserver recipientObserver = recipient -> { - updateState(getNewState(s -> s.updateRecipient(recipient))); - }; - - private MessageRequestFragmentViewModel(@NonNull MessageRequestFragmentRepository repository) { - internalState.setValue(new MessageRequestFragmentState(MessageRequestFragmentState.MessageRequestState.LOADING, null, null, null, 0)); - this.repository = repository; - - loadRecipient(); - loadMessageRecord(); - loadGroups(); - loadMemberCount(); - } - - @Override - protected void onCleared() { - repository.getLiveRecipient().removeForeverObserver(recipientObserver); - } - - public @NonNull LiveData getState() { - return internalState; - } - - @MainThread - public void accept() { - repository.acceptMessageRequest(() -> { - MessageRequestFragmentState state = internalState.getValue(); - updateState(state.updateMessageRequestState(MessageRequestFragmentState.MessageRequestState.ACCEPTED)); - }); - } - - @MainThread - public void delete() { - repository.deleteMessageRequest(() -> { - MessageRequestFragmentState state = internalState.getValue(); - updateState(state.updateMessageRequestState(MessageRequestFragmentState.MessageRequestState.DELETED)); - }); - } - - @MainThread - public void block() { - repository.blockMessageRequest(() -> { - MessageRequestFragmentState state = internalState.getValue(); - updateState(state.updateMessageRequestState(MessageRequestFragmentState.MessageRequestState.BLOCKED)); - }); - } - - private void updateState(@NonNull MessageRequestFragmentState newState) { - Log.i(TAG, "updateState: " + newState); - internalState.setValue(newState); - } - - private void loadRecipient() { - repository.getLiveRecipient().observeForever(recipientObserver); - repository.refreshRecipient(); - } - - private void loadMessageRecord() { - repository.getMessageRecord(messageRecord -> { - MessageRequestFragmentState newState = getNewState(s -> s.updateMessageRecord(messageRecord)); - updateState(newState); - }); - } - - private void loadGroups() { - repository.getGroups(groups -> { - MessageRequestFragmentState newState = getNewState(s -> s.updateGroups(groups)); - updateState(newState); - }); - } - - private void loadMemberCount() { - repository.getMemberCount(memberCount -> { - MessageRequestFragmentState newState = getNewState(s -> s.updateMemberCount(memberCount == null ? 0 : memberCount)); - updateState(newState); - }); - } - - private @NonNull MessageRequestFragmentState getNewState(@NonNull Function stateTransformer) { - MessageRequestFragmentState oldState = internalState.getValue(); - MessageRequestFragmentState newState = stateTransformer.apply(oldState); - return newState.updateMessageRequestState(getUpdatedRequestState(newState)); - } - - private static @NonNull MessageRequestFragmentState.MessageRequestState getUpdatedRequestState(@NonNull MessageRequestFragmentState state) { - if (state.messageRequestState != MessageRequestFragmentState.MessageRequestState.LOADING) { - return state.messageRequestState; - } - - if (state.messageRecord != null && state.recipient != null && state.groups != null) { - return MessageRequestFragmentState.MessageRequestState.PENDING; - } - - return MessageRequestFragmentState.MessageRequestState.LOADING; - } - - public static class Factory implements ViewModelProvider.Factory { - private final Context context; - private final long threadId; - private final RecipientId recipientId; - - public Factory(@NonNull Context context, long threadId, @NonNull RecipientId recipientId) { - this.context = context; - this.threadId = threadId; - this.recipientId = recipientId; - } - - @SuppressWarnings("unchecked") - @Override - public @NonNull T create(@NonNull Class modelClass) { - return (T) new MessageRequestFragmentViewModel(new MessageRequestFragmentRepository(context, recipientId, threadId)); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestMegaphoneActivity.java b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestMegaphoneActivity.java new file mode 100644 index 0000000000..aeed83f0b1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestMegaphoneActivity.java @@ -0,0 +1,73 @@ +package org.thoughtcrime.securesms.messagerequests; + +import android.content.Intent; +import android.os.Bundle; +import android.widget.TextView; + +import androidx.annotation.Nullable; + +import com.airbnb.lottie.LottieAnimationView; + +import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.megaphone.Megaphones; +import org.thoughtcrime.securesms.profiles.ProfileName; +import org.thoughtcrime.securesms.profiles.edit.EditProfileActivity; +import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; +import org.thoughtcrime.securesms.util.DynamicTheme; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +public class MessageRequestMegaphoneActivity extends PassphraseRequiredActionBarActivity { + + public static final short EDIT_PROFILE_REQUEST_CODE = 24563; + + private DynamicTheme dynamicTheme = new DynamicNoActionBarTheme(); + + @Override + public void onCreate(@Nullable Bundle savedInstanceState, boolean isReady) { + dynamicTheme.onCreate(this); + + setContentView(R.layout.message_requests_megaphone_activity); + + + LottieAnimationView lottie = findViewById(R.id.message_requests_lottie); + TextView profileNameButton = findViewById(R.id.message_requests_confirm_profile_name); + + lottie.setAnimation(R.raw.lottie_message_requests_splash); + lottie.playAnimation(); + + profileNameButton.setOnClickListener(v -> { + final Intent profile = new Intent(this, EditProfileActivity.class); + + profile.putExtra(EditProfileActivity.SHOW_TOOLBAR, false); + profile.putExtra(EditProfileActivity.NEXT_BUTTON_TEXT, R.string.save); + + startActivityForResult(profile, EDIT_PROFILE_REQUEST_CODE); + }); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + if (requestCode == EDIT_PROFILE_REQUEST_CODE && + resultCode == RESULT_OK && + TextSecurePreferences.getProfileName(this) != ProfileName.EMPTY) { + ApplicationDependencies.getMegaphoneRepository().markFinished(Megaphones.Event.MESSAGE_REQUESTS); + setResult(RESULT_OK); + finish(); + } + } + + @Override + public void onBackPressed() { + } + + @Override + protected void onResume() { + super.onResume(); + + dynamicTheme.onResume(this); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestFragmentRepository.java b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java similarity index 65% rename from app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestFragmentRepository.java rename to app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java index 8a0cad9f43..b20ca3dd37 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestFragmentRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java @@ -25,46 +25,26 @@ import org.whispersystems.libsignal.util.guava.Optional; import java.util.List; -public class MessageRequestFragmentRepository { +public class MessageRequestRepository { private final Context context; - private final RecipientId recipientId; - private final long threadId; - private final LiveRecipient liveRecipient; - public MessageRequestFragmentRepository(@NonNull Context context, @NonNull RecipientId recipientId, long threadId) { + public MessageRequestRepository(@NonNull Context context) { this.context = context.getApplicationContext(); - this.recipientId = recipientId; - this.threadId = threadId; - this.liveRecipient = Recipient.live(recipientId); } - public LiveRecipient getLiveRecipient() { - return liveRecipient; + public LiveRecipient getLiveRecipient(@NonNull RecipientId recipientId) { + return Recipient.live(recipientId); } - public void refreshRecipient() { - SignalExecutors.BOUNDED.execute(liveRecipient::refresh); - } - - public void getMessageRecord(@NonNull Consumer onMessageRecordLoaded) { - SimpleTask.run(() -> { - MmsSmsDatabase mmsSmsDatabase = DatabaseFactory.getMmsSmsDatabase(context); - try (Cursor cursor = mmsSmsDatabase.getConversation(threadId, 0, 1)) { - if (!cursor.moveToFirst()) return null; - return mmsSmsDatabase.readerFor(cursor).getCurrent(); - } - }, onMessageRecordLoaded::accept); - } - - public void getGroups(@NonNull Consumer> onGroupsLoaded) { + public void getGroups(@NonNull RecipientId recipientId, @NonNull Consumer> onGroupsLoaded) { SimpleTask.run(() -> { GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); return groupDatabase.getGroupNamesContainingMember(recipientId); }, onGroupsLoaded::accept); } - public void getMemberCount(@NonNull Consumer onMemberCountLoaded) { + public void getMemberCount(@NonNull RecipientId recipientId, @NonNull Consumer onMemberCountLoaded) { SimpleTask.run(() -> { GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); Optional groupRecord = groupDatabase.getGroup(recipientId); @@ -72,10 +52,15 @@ public class MessageRequestFragmentRepository { }, onMemberCountLoaded::accept); } - public void acceptMessageRequest(@NonNull Runnable onMessageRequestAccepted) { + public void getMessageRequestAccepted(long threadId, @NonNull Consumer recipientRequestAccepted) { + SimpleTask.run(() -> RecipientUtil.isThreadMessageRequestAccepted(context, threadId), + recipientRequestAccepted::accept); + } + + public void acceptMessageRequest(@NonNull LiveRecipient liveRecipient, long threadId, @NonNull Runnable onMessageRequestAccepted) { SimpleTask.run(() -> { RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context); - recipientDatabase.setProfileSharing(recipientId, true); + recipientDatabase.setProfileSharing(liveRecipient.getId(), true); liveRecipient.refresh(); List messageIds = DatabaseFactory.getThreadDatabase(context) @@ -87,7 +72,7 @@ public class MessageRequestFragmentRepository { }, v -> onMessageRequestAccepted.run()); } - public void deleteMessageRequest(@NonNull Runnable onMessageRequestDeleted) { + public void deleteMessageRequest(long threadId, @NonNull Runnable onMessageRequestDeleted) { SimpleTask.run(() -> { ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context); threadDatabase.deleteConversation(threadId); @@ -95,7 +80,7 @@ public class MessageRequestFragmentRepository { }, v -> onMessageRequestDeleted.run()); } - public void blockMessageRequest(@NonNull Runnable onMessageRequestBlocked) { + public void blockMessageRequest(@NonNull LiveRecipient liveRecipient, @NonNull Runnable onMessageRequestBlocked) { SimpleTask.run(() -> { Recipient recipient = liveRecipient.resolve(); RecipientUtil.block(context, recipient); diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestViewModel.java new file mode 100644 index 0000000000..9f08118e94 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestViewModel.java @@ -0,0 +1,177 @@ +package org.thoughtcrime.securesms.messagerequests; + +import android.content.Context; + +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Transformations; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +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.SingleLiveEvent; +import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; +import org.thoughtcrime.securesms.util.livedata.LiveDataTriple; + +import java.util.Collections; +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 MessageRequestRepository repository; + + private LiveRecipient liveRecipient; + private long threadId; + + @SuppressWarnings("CodeBlock2Expr") + private final RecipientForeverObserver recipientObserver = recipient -> { + if (Recipient.self().equals(recipient) || recipient.isBlocked() || recipient.isForceSmsSelection() || !recipient.isRegistered()) { + shouldDisplayMessageRequest.setValue(false); + } else { + loadMessageRequestAccepted(); + } + this.recipient.setValue(recipient); + }; + + private MessageRequestViewModel(MessageRequestRepository repository) { + this.repository = repository; + } + + public void setConversationInfo(@NonNull RecipientId recipientId, long threadId) { + if (liveRecipient != null) { + liveRecipient.removeForeverObserver(recipientObserver); + } + + liveRecipient = Recipient.live(recipientId); + this.threadId = threadId; + + loadRecipient(); + loadGroups(); + loadMemberCount(); + } + + @Override + protected void onCleared() { + if (liveRecipient != null) { + liveRecipient.removeForeverObserver(recipientObserver); + } + } + + public LiveData getShouldDisplayMessageRequest() { + return shouldDisplayMessageRequest; + } + + public LiveData getRecipient() { + return recipient; + } + + public LiveData getRecipientInfo() { + return recipientInfo; + } + + public LiveData getMesasgeRequestStatus() { + return status; + } + + @MainThread + public void accept() { + repository.acceptMessageRequest(liveRecipient, threadId, () -> { + status.setValue(Status.ACCEPTED); + }); + } + + @MainThread + public void delete() { + repository.deleteMessageRequest(threadId, () -> { + status.setValue(Status.DELETED); + }); + } + + @MainThread + public void block() { + repository.blockMessageRequest(liveRecipient, () -> { + status.setValue(Status.BLOCKED); + }); + } + + private void loadRecipient() { + liveRecipient.observeForever(recipientObserver); + SignalExecutors.BOUNDED.execute(liveRecipient::refresh); + } + + private void loadGroups() { + repository.getGroups(liveRecipient.getId(), this.groups::setValue); + } + + private void loadMemberCount() { + repository.getMemberCount(liveRecipient.getId(), memberCount -> { + this.memberCount.setValue(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; + } + + @NonNull + @Override + public T create(@NonNull Class modelClass) { + return (T) new MessageRequestViewModel(new MessageRequestRepository(context.getApplicationContext())); + } + } + + public static class RecipientInfo { + private final @Nullable Recipient recipient; + private final int groupMemberCount; + private final @NonNull List sharedGroups; + + private RecipientInfo(@Nullable Recipient recipient, @Nullable Integer groupMemberCount, @Nullable List sharedGroups) { + this.recipient = recipient; + this.groupMemberCount = groupMemberCount == null ? 0 : groupMemberCount; + this.sharedGroups = sharedGroups == null ? Collections.emptyList() : sharedGroups; + } + + @Nullable + public Recipient getRecipient() { + return recipient; + } + + public int getGroupMemberCount() { + return groupMemberCount; + } + + @NonNull + public List getSharedGroups() { + return sharedGroups; + } + } + + public enum Status { + BLOCKED, + DELETED, + ACCEPTED + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsBottomView.java b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsBottomView.java new file mode 100644 index 0000000000..54c7909e0b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestsBottomView.java @@ -0,0 +1,58 @@ +package org.thoughtcrime.securesms.messagerequests; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; +import android.widget.TextView; + +import androidx.constraintlayout.widget.ConstraintLayout; + +import org.thoughtcrime.securesms.R; + +public class MessageRequestsBottomView extends ConstraintLayout { + + private TextView question; + private View accept; + private View block; + private View delete; + + public MessageRequestsBottomView(Context context) { + super(context); + } + + public MessageRequestsBottomView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public MessageRequestsBottomView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + + 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); + } + + public void setQuestionText(CharSequence questionText) { + question.setText(questionText); + } + + public void setAcceptOnClickListener(OnClickListener acceptOnClickListener) { + accept.setOnClickListener(acceptOnClickListener); + } + + public void setDeleteOnClickListener(OnClickListener deleteOnClickListener) { + delete.setOnClickListener(deleteOnClickListener); + } + + public void setBlockOnClickListener(OnClickListener blockOnClickListener) { + block.setOnClickListener(blockOnClickListener); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java index 12e32420a7..4503effff5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java @@ -5,6 +5,7 @@ import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.os.AsyncTask; + import androidx.annotation.NonNull; import androidx.core.app.NotificationManagerCompat; @@ -78,15 +79,20 @@ public class MarkReadReceiver extends BroadcastReceiver { ApplicationDependencies.getJobManager().add(new MultiDeviceReadUpdateJob(syncMessageIds)); - Map> recipientIdMap = Stream.of(markedReadMessages) - .map(MarkedMessageInfo::getSyncMessageId) - .collect(Collectors.groupingBy(SyncMessageId::getRecipientId)); + Map> threadToInfo = Stream.of(markedReadMessages) + .collect(Collectors.groupingBy(MarkedMessageInfo::getThreadId)); - for (Map.Entry> entry : recipientIdMap.entrySet()) { - List timestamps = Stream.of(entry.getValue()).map(SyncMessageId::getTimetamp).toList(); + Stream.of(threadToInfo).forEach(threadToInfoEntry -> { + Map> idMapForThread = Stream.of(threadToInfoEntry.getValue()) + .map(MarkedMessageInfo::getSyncMessageId) + .collect(Collectors.groupingBy(SyncMessageId::getRecipientId)); - ApplicationDependencies.getJobManager().add(new SendReadReceiptJob(entry.getKey(), timestamps)); - } + Stream.of(idMapForThread).forEach(entry -> { + List timestamps = Stream.of(entry.getValue()).map(SyncMessageId::getTimetamp).toList(); + + ApplicationDependencies.getJobManager().add(new SendReadReceiptJob(threadToInfoEntry.getKey(), entry.getKey(), timestamps)); + }); + }); } private static void scheduleDeletion(Context context, ExpirationInfo expirationInfo) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/ProfileName.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/ProfileName.java index 5dc2e39d46..280f1b0632 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/ProfileName.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/ProfileName.java @@ -74,7 +74,7 @@ public final class ProfileName implements Parcelable { * Deserializes a profile name, trims if exceeds the limits. */ public static @NonNull ProfileName fromSerialized(@Nullable String profileName) { - if (profileName == null) { + if (profileName == null || profileName.isEmpty()) { return EMPTY; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileActivity.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileActivity.java index b2398fd3c8..610814c71c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileActivity.java @@ -47,6 +47,7 @@ public class EditProfileActivity extends BaseActionBarActivity implements EditPr @Override public void onProfileNameUploadCompleted() { + setResult(RESULT_OK); finish(); } } 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 7acc15ee55..83e2b4b6c4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java @@ -55,7 +55,8 @@ public class Recipient { public static final Recipient UNKNOWN = new Recipient(RecipientId.UNKNOWN, new RecipientDetails()); - private static final String TAG = Log.tag(Recipient.class); + private static final FallbackPhotoProvider DEFAULT_FALLBACK_PHOTO_PROVIDER = new FallbackPhotoProvider(); + private static final String TAG = Log.tag(Recipient.class); private final RecipientId id; private final boolean resolving; @@ -567,20 +568,28 @@ public class Recipient { } public @NonNull Drawable getFallbackContactPhotoDrawable(Context context, boolean inverted) { - return getFallbackContactPhoto().asDrawable(context, getColor().toAvatarColor(context), inverted); + return getFallbackContactPhotoDrawable(context, inverted, DEFAULT_FALLBACK_PHOTO_PROVIDER); } - public @NonNull Drawable getSmallFallbackContactPhotoDrawable(Context context, boolean inverted) { - return getFallbackContactPhoto().asSmallDrawable(context, getColor().toAvatarColor(context), inverted); + public @NonNull Drawable getFallbackContactPhotoDrawable(Context context, boolean inverted, @Nullable FallbackPhotoProvider fallbackPhotoProvider) { + return getFallbackContactPhoto(Util.firstNonNull(fallbackPhotoProvider, DEFAULT_FALLBACK_PHOTO_PROVIDER)).asDrawable(context, getColor().toAvatarColor(context), inverted); + } + + public @NonNull Drawable getSmallFallbackContactPhotoDrawable(Context context, boolean inverted, @Nullable FallbackPhotoProvider fallbackPhotoProvider) { + return getFallbackContactPhoto(Util.firstNonNull(fallbackPhotoProvider, DEFAULT_FALLBACK_PHOTO_PROVIDER)).asSmallDrawable(context, getColor().toAvatarColor(context), inverted); } public @NonNull FallbackContactPhoto getFallbackContactPhoto() { - if (localNumber) return new ResourceContactPhoto(R.drawable.ic_note_to_self); - if (isResolving()) return new TransparentContactPhoto(); - else if (isGroupInternal()) return new ResourceContactPhoto(R.drawable.ic_group_outline_40, R.drawable.ic_group_outline_20, R.drawable.ic_group_large); - else if (isGroup()) return new ResourceContactPhoto(R.drawable.ic_group_outline_40, R.drawable.ic_group_outline_20, R.drawable.ic_group_large); - else if (!TextUtils.isEmpty(name)) return new GeneratedContactPhoto(name, R.drawable.ic_profile_outline_40); - else return new ResourceContactPhoto(R.drawable.ic_profile_outline_40, R.drawable.ic_profile_outline_20, R.drawable.ic_person_large); + return getFallbackContactPhoto(DEFAULT_FALLBACK_PHOTO_PROVIDER); + } + + public @NonNull FallbackContactPhoto getFallbackContactPhoto(@NonNull FallbackPhotoProvider fallbackPhotoProvider) { + if (localNumber) return fallbackPhotoProvider.getPhotoForLocalNumber(); + if (isResolving()) return fallbackPhotoProvider.getPhotoForResolvingRecipient(); + else if (isGroupInternal()) return fallbackPhotoProvider.getPhotoForGroup(); + else if (isGroup()) return fallbackPhotoProvider.getPhotoForGroup(); + else if (!TextUtils.isEmpty(name)) return fallbackPhotoProvider.getPhotoForRecipientWithName(name); + else return fallbackPhotoProvider.getPhotoForRecipientWithoutName(); } public @Nullable ContactPhoto getContactPhoto() { @@ -734,6 +743,29 @@ public class Recipient { return Objects.hash(id); } + public static class FallbackPhotoProvider { + public @NonNull FallbackContactPhoto getPhotoForLocalNumber() { + return new ResourceContactPhoto(R.drawable.ic_note_34, R.drawable.ic_note_24); + } + + public @NonNull FallbackContactPhoto getPhotoForResolvingRecipient() { + return new TransparentContactPhoto(); + } + + public @NonNull FallbackContactPhoto getPhotoForGroup() { + return new ResourceContactPhoto(R.drawable.ic_group_outline_34, R.drawable.ic_group_outline_20, R.drawable.ic_group_outline_48); + } + + public @NonNull FallbackContactPhoto getPhotoForRecipientWithName(String name) { + return new GeneratedContactPhoto(name, R.drawable.ic_profile_outline_40); + } + + public @NonNull FallbackContactPhoto getPhotoForRecipientWithoutName() { + return new ResourceContactPhoto(R.drawable.ic_profile_outline_40, R.drawable.ic_profile_outline_20, R.drawable.ic_profile_outline_48); + } + + } + private static class MissingAddressError extends AssertionError { } 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 b2897aae25..b9782beb68 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientUtil.java @@ -11,6 +11,8 @@ 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; @@ -108,16 +110,55 @@ public class RecipientUtil { ApplicationDependencies.getJobManager().add(new MultiDeviceBlockedUpdateJob()); } + @WorkerThread + public static boolean isThreadMessageRequestAccepted(@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(); - ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context); - long threadId = threadDatabase.getThreadIdFor(resolved); - boolean hasSentMessage = threadDatabase.getLastSeenAndHasSent(threadId).second() == Boolean.TRUE; + long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdIfExistsFor(resolved); + boolean hasSentMessage = DatabaseFactory.getMmsSmsDatabase(context) + .getOutgoingSecureConversationCount(threadId) != 0; + boolean noSecureMessagesInThread = DatabaseFactory.getMmsSmsDatabase(context) + .getSecureConversationCount(threadId) == 0; - return hasSentMessage || resolved.isProfileSharing() || resolved.isSystemContact(); + return noSecureMessagesInThread || hasSentMessage || resolved.isProfileSharing() || resolved.isSystemContact(); + } + + @WorkerThread + public static void shareProfileIfFirstSecureMessage(@NonNull Context context, @NonNull Recipient recipient) { + if (!FeatureFlags.messageRequests()) { + return; + } + + long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdIfExistsFor(recipient); + boolean firstMessage = DatabaseFactory.getMmsSmsDatabase(context) + .getOutgoingSecureConversationCount(threadId) == 0; + + if (firstMessage) { + DatabaseFactory.getRecipientDatabase(context).setProfileSharing(recipient.getId(), true); + } } } 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 e64a3caa57..ccbe3fd389 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -47,7 +47,6 @@ public final class FeatureFlags { private static final long FETCH_INTERVAL = TimeUnit.HOURS.toMillis(2); private static final String UUIDS = generateKey("uuids"); - private static final String PROFILE_DISPLAY = generateKey("profileDisplay"); private static final String MESSAGE_REQUESTS = generateKey("messageRequests"); private static final String USERNAMES = generateKey("usernames"); private static final String STORAGE_SERVICE = generateKey("storageService"); @@ -65,7 +64,8 @@ public final class FeatureFlags { VIDEO_TRIMMING, PINS_FOR_ALL, PINS_MEGAPHONE_KILL_SWITCH, - PROFILE_NAMES_MEGAPHONE + PROFILE_NAMES_MEGAPHONE, + MESSAGE_REQUESTS ); /** @@ -139,7 +139,7 @@ public final class FeatureFlags { /** Favoring profile names when displaying contacts. */ public static synchronized boolean profileDisplay() { - return getValue(PROFILE_DISPLAY, false); + return messageRequests(); } /** MessageRequest stuff */ @@ -172,7 +172,7 @@ public final class FeatureFlags { } /** Safety switch for disabling profile names megaphone */ - public static boolean profileNamesMegaphoneEnabled() { + public static boolean profileNamesMegaphone() { return getValue(PROFILE_NAMES_MEGAPHONE, false) && TextSecurePreferences.getFirstInstallVersion(ApplicationDependencies.getApplication()) < 600; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/HtmlUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/HtmlUtil.java new file mode 100644 index 0000000000..186c69bd50 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/HtmlUtil.java @@ -0,0 +1,9 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.NonNull; + +public class HtmlUtil { + public static @NonNull String bold(@NonNull String target) { + return "" + target + ""; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Triple.java b/app/src/main/java/org/thoughtcrime/securesms/util/Triple.java new file mode 100644 index 0000000000..86d9db9c8a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Triple.java @@ -0,0 +1,43 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.Nullable; +import androidx.core.util.ObjectsCompat; + +public class Triple { + + private final A a; + private final B b; + private final C c; + + public Triple(@Nullable A a, @Nullable B b, @Nullable C c) { + this.a = a; + this.b = b; + this.c = c; + } + + public @Nullable A first() { + return a; + } + + public @Nullable B second() { + return b; + } + + public @Nullable C third() { + return c; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof Triple)) { + return false; + } + Triple t = (Triple) o; + return ObjectsCompat.equals(t.a, a) && ObjectsCompat.equals(t.b, b) && ObjectsCompat.equals(t.c, c); + } + + @Override + public int hashCode() { + return (a == null ? 0 : a.hashCode()) ^ (b == null ? 0 : b.hashCode()) ^ (c == null ? 0 : c.hashCode()); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Util.java b/app/src/main/java/org/thoughtcrime/securesms/util/Util.java index 69f80a9c26..06c3fd39af 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/Util.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Util.java @@ -320,6 +320,17 @@ public class Util { return Optional.fromNullable(simCountryIso != null ? simCountryIso.toUpperCase() : null); } + @SafeVarargs + public static @NonNull T firstNonNull(T ... ts) { + for (T t : ts) { + if (t != null) { + return t; + } + } + + throw new IllegalStateException("All choices were null."); + } + public static List> partition(List list, int partitionSize) { List> results = new LinkedList<>(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/livedata/LiveDataTriple.java b/app/src/main/java/org/thoughtcrime/securesms/util/livedata/LiveDataTriple.java new file mode 100644 index 0000000000..4eed226775 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/livedata/LiveDataTriple.java @@ -0,0 +1,136 @@ +package org.thoughtcrime.securesms.util.livedata; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MediatorLiveData; + +import org.thoughtcrime.securesms.util.Triple; +import org.whispersystems.libsignal.util.Pair; + +public final class LiveDataTriple extends MediatorLiveData> { + private A a; + private B b; + private C c; + + public LiveDataTriple(@NonNull LiveData liveDataA, + @NonNull LiveData liveDataB, + @NonNull LiveData liveDataC) + { + this(liveDataA, liveDataB, liveDataC, null, null, null); + } + + public LiveDataTriple(@NonNull LiveData liveDataA, + @NonNull LiveData liveDataB, + @NonNull LiveData liveDataC, + @Nullable A initialA, + @Nullable B initialB, + @Nullable C initialC) + { + a = initialA; + b = initialB; + c = initialC; + setValue(new Triple<>(a, b, c)); + + if (liveDataA == liveDataB && liveDataA == liveDataC) { + + addSource(liveDataA, a -> { + if (a != null) { + this.a = a; + + //noinspection unchecked: A is B if live datas are same instance + this.b = (B) a; + + //noinspection unchecked: A is C if live datas are same instance + this.c = (C) a; + } + + setValue(new Triple<>(a, b, c)); + }); + + } else if (liveDataA == liveDataB) { + + addSource(liveDataA, a -> { + if (a != null) { + this.a = a; + + //noinspection unchecked: A is B if live datas are same instance + this.b = (B) a; + } + + setValue(new Triple<>(a, b, c)); + }); + + addSource(liveDataC, c -> { + if (c != null) { + this.c = c; + } + setValue(new Triple<>(a, b, c)); + }); + + } else if (liveDataA == liveDataC) { + + addSource(liveDataA, a -> { + if (a != null) { + this.a = a; + + //noinspection unchecked: A is C if live datas are same instance + this.c = (C) a; + } + + setValue(new Triple<>(a, b, c)); + }); + + addSource(liveDataB, b -> { + if (b != null) { + this.b = b; + } + setValue(new Triple<>(a, b, c)); + }); + + } else if (liveDataB == liveDataC) { + + addSource(liveDataB, b -> { + if (b != null) { + this.b = b; + + //noinspection unchecked: A is C if live datas are same instance + this.c = (C) b; + } + + setValue(new Triple<>(a, b, c)); + }); + + addSource(liveDataA, a -> { + if (a != null) { + this.a = a; + } + setValue(new Triple<>(a, b, c)); + }); + + } else { + + addSource(liveDataA, a -> { + if (a != null) { + this.a = a; + } + setValue(new Triple<>(a, b, c)); + }); + + addSource(liveDataB, b -> { + if (b != null) { + this.b = b; + } + setValue(new Triple<>(a, b, c)); + }); + + addSource(liveDataC, c -> { + if (c != null) { + this.c = c; + } + setValue(new Triple<>(a, b, c)); + }); + + } + } +} diff --git a/app/src/main/res/drawable-hdpi/ic_note_to_self.webp b/app/src/main/res/drawable-hdpi/ic_note_to_self.webp deleted file mode 100644 index 37fd3ee077cf6431fa49ce65244064b396268916..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 128 zcmV-`0Du2dNk&F^00012MM6+kP&iC%0000lBftm{H!%P6KmP>Twy_}$;U-^z3o(Rn zu#IFpjkp6Nk&Fg00012MM6+kP&iCT0000l7r+G&A29#(KmP&&LV{EW)&pFGp(v81 yW*uM&y;%a5?oG}~K}7$FrfpI+xi_#cm*T)nZ-E&D2(Uu<--9|Ud|AeY<>@|kjw5FP diff --git a/app/src/main/res/drawable-mdpi/message_requests_megaphone.png b/app/src/main/res/drawable-mdpi/message_requests_megaphone.png new file mode 100644 index 0000000000000000000000000000000000000000..e741ffe5f38e89e8525af51769d19b66492e0a90 GIT binary patch literal 3514 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xhdpi/ic_note_to_self.webp b/app/src/main/res/drawable-xhdpi/ic_note_to_self.webp deleted file mode 100644 index b0d2288b01741c2e3c5856006583288ad03cbeb9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 114 zcmWIYbaTsMU|3a0{p#{QqqKpXH{R^NfQbeHBj_#19KbT-_$r zo!TUx*yF&ouzvOF6?N`k>X|aSx38GVKI_$=i4_ttulyEIov?HIcHx=TmDUqMjDvOe SJ`3?Le);pFu9;lMTLu8y;W6C+ diff --git a/app/src/main/res/drawable-xhdpi/message_requests_megaphone.png b/app/src/main/res/drawable-xhdpi/message_requests_megaphone.png new file mode 100644 index 0000000000000000000000000000000000000000..85d40a093e7247451d984f6808ca14e3a55c9fcd GIT binary patch literal 9474 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 IzO&W^09VUGQUCw| diff --git a/app/src/main/res/drawable-xxhdpi/message_requests_megaphone.png b/app/src/main/res/drawable-xxhdpi/message_requests_megaphone.png new file mode 100644 index 0000000000000000000000000000000000000000..73897db4ee35c08cbe35d682715bac9879c446cf GIT binary patch literal 17357 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxxhdpi/ic_note_to_self.webp b/app/src/main/res/drawable-xxxhdpi/ic_note_to_self.webp deleted file mode 100644 index 7046e0ea8a6d06f316d3d904d8ce9591f4da8c06..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 152 zcmV;J0B8SFNk&GH00012MM6+kP&iD40000lU%(d-KQRCEKmP;)q`?dhVU$r0a3O|6 z6oK2www>mJq+BKV4qO2?WoUu|Z2!@HNA{s8qW=@13Gp(5hmd+i_W`ScX-cme;f2>y zXRo6k-iTYvx2K#^cpk7hpzu6kb3oyFz~+GLd|?0II_lxI)Y+@3g|`}*ru0-ZdqrM| GgC<0ct41&Y diff --git a/app/src/main/res/drawable-xxxhdpi/message_requests_megaphone.png b/app/src/main/res/drawable-xxxhdpi/message_requests_megaphone.png new file mode 100644 index 0000000000000000000000000000000000000000..af5bdc91d2162c75e1feee150faf78371c4dc79a GIT binary patch literal 26820 zcmd>_^;;D0_y1>??nR_qI;1-ngclu>3J6GtbV=?mDM(33NJ@8?G$>Cx$o!9HRqhiiPY9qBEY4>1pt6RMOi@y06_n`AOIWlUt{84Z1b+Lv&2EPc)hsI`tXdNd`Rg1PzU-U*t1mS1}vaA9tKg6fYN=JlxI zH5JBRX!2Is&o8$lR$NzWxE@V7{YY=R%(9ycUe-3@;K*tV1gNk$kv=;fvAR!LaYfaz zwSMK4Ks=*2;Oa~b37}5W(@`-_`q|ofPaW6Me)0jyp!#THm971)397=9TtBn-TYbAq z)gJ9_z#3Y%gqzFnhkr*al<_WP?+zmUH%Snf+Nw_ps*AJJxj13~^qZ{NNC!I0d5Yeo zcOl!(e`ohAAM(&w(6tF=+0#1n6B;F#YkNT_LBx39WtXuYJp2lAjPB!EBO7JrZYq%ic!!Kb!Rzpead%wac{7gJf+T z6&3>e$U&}gtdoHMvyRPAy0-a_|Cp>dsr~FM$YAt@t0bS1<<~vs|Ba}|0wn28LUeHe zmSmxydfF4GALyai&nH6X{aJn47G|Zhxo;?gB!GI~=7#RDK(s3v^G8(B@+&ZLtTCO z=W?_iaDM$;2L-X5D-D}!|L@-^JM$})iqkZvxvF84064g-<+kzZWkX23Dgf{*5+0U+ zRqrH-tQTVjhn(X%vD*)T%oR#5?u$5G?UV}Y|3-vjIrQS^!E)*fn6sCcoI7t2Rj@LsP^%e(D$6WU3dS$ge&n1 zMf3>G&jN6fr;66HYX3GcOY$@?BP5_#gK1G{{zxX+2@haDRogLDnUlAo%J?xk39Y8J zUjhN&uMhnlttT~A_Wx5VDy!B4bsLTfHD-!bJ4IV;`RH9wgme9SBQ9BKbnVC(C_b<) zl4*tT?ug&-OQ4nKCx|4B*Z&c12&_lEBm(SqHq|(s9=q+myhum0r11{Dsl5;8b5Y1k ziKOXF?X^EX&6SVfv0 z&4~Kt%a`3tD_0_^+1bZph9!FDHa3`9sE3{2A=4{?kF#}I1#!tqtQk=c4d=#F%=7Y^ z(5aSz2OoOwG?Y<^L0R|KmemN!yywEg1DOfXlW%Ajl!3*G^j(kkj~Gu%1QB%V&_VI+l>TTLIw;t@8+1hX$Ko_z=+fx5|vxT5s*BW_h~SMP{b z?`*StF?mdya}aA=zY|Zk6193-Ja&ZGe})1l>2qzE9s8{vYWMi6NM@6Otw^5YN74{ES>I4Q9)=a6J7T z)}VH$FcAD=duK;%v%e6ujTwNthRLd{t9L2M-o1W#`z3vZ^6iRmt`vNwMHuxYh& zEO)EOkr;j4_vSl!yK)MLTu!1Z{;|GHIo{_M_aA;!-gT+y-v-gghpXz}MxUqT%DfP^ z%eoCLmUYoY9*3^19f3u@cE@ukSHo3O_hAW50fSwk605`l9dTHkIN=Lmy|! zyygSD_E~>ChRI5ubfKN3jRpLNVzfM7X*VvyvJ|&E*^#+!pke{<;& zFK%Mi!Uo$Bnix5}GG6Q;KFX$66{Q36BhwP}e(bEVN4tDek&f~;91Cu~*)j4O8-1{a zq@bRe|4n*UHidM-^dk%otK#~NBDCHg+gnENQmE63;aj$P_YTC`Q!I#AbmuJFv475S zIub`&LsT6Hd^(gJ!Ii?1=%iK`vNs{J@Z@AtAN(e*3^7pl(?yYRNS+`>7(7fZ=66vX z3#ngnGGx2MHhtJ2cZ_G?lw)AVH`9vKUmMQg&-FwNP-pYH_67oC%negoUVTojj!SI4 zyk>}ly=!^zGcykV$6(2WEFS8xfa9iDK}%ztt&gnAV1&iS!B(dMgS+POY#*D@Io{V# zObVEnzd4FyVtO~i>c-z_Z_BNO9!z)6+Q{F}Yq6&i2|i(e&}9X)p@Zx1Un5cDM30a^ z-b+rdLob_lFU*IZG51t6vf{qigWJ_fv^z7ef0$WH(prlgl9BqDcfZ$X4~TR}U~aBw zij}#16MIa;&62+Q9OVmBbK=b(V&8uL*(~DVPXu)K8c<&;kzg9EHuj~eCF#s9kQI3R z*%6{$QeeBke!JLg;b~1+PgQ0|4o~%mD&lGeXZT_$6Pqdx%gV@9qIll@$fwL=mMm&% z5x+T0=`0rpdE#1lt|ewa%qfm(rQNrf1w~w&ikqpT%Ls`ek)ZFr`gRnK5d6d zPBtw$7_*}E{+JpBo}&da$kSg8B67|K#wfMg{WPuaBBk$zEWb|?9(q;O zav68D|M;G!pTlMrb`?5_?v4lk!7~kvcC4m?XVM#Q{Ry9WLQ4Jr9u}5%f{ZpeWx+zW za+<4ELI|Tta`>f=35vS&l)B-ZNH@e+l`j|&coYrj72Vw=lDWx|i~vr0$UC&P7>|mn z5xbN9jQ%%l486*VD<^FHZ6$a3DD(bhRqA=XrA+PdT-miAc|3QO|M>pIhvx2A`nchQ zhMezu68z`5)Nk;7pqgNlMX8Wp7l_aG;7*9@%aZ-xfEK8Ji}Ta6^8@quNt*f?(`Afj zBmvL=I(e1Pm<5T?%GO-N-62Op*ZPR%q5n!WA_S)Dx%6n6)<=A8mD zV40q|yA3XD5!-u_B35!5&!F)DLh(7wB1|SFD_H!>CaOkW7)D*w80rXW*y~% z7_^_brEwQd&GZ*zCO+}Ce{`_Hn)XU~U3hFvU&5sUzvGxo2W~aP5l8b8R9Vxgy{0bp z;C(r2Lcotfxl_aPa61k8)!>n3$`0K6=hw3JDl`_#-?4OqgP$kz9Qz^MV98jJHzeTh z*AJ7{8F&HSj$Tp~PGF*i5CK@yN1U|I{ZuO_=z;t>cIqE+!eWVRfQTodyG?%pFpT%< zFmM>l^)))nDOW!#?d&Fdx;5E|l=b=fri=QF#Lx>9@roN5M!5AUv58LfcQZ`P;jgy~ zH!}0)Ks@o+G`X;aAkpK+yU$vEl-@144Zmx?jqv05SMXfFGgRcCzR7CmR5h2&@ggl{ z3wl>_h@v#YLgflz5h6hoCPtxQ9%IGq@+ci0y;2EWevvwAY3+HP;*5J2ZT|;yz5Ml1 zE^6Q3B;3Wt~T;|ca-w-TlZuEo`hMB-xC_r2Zu_uljLl|xT`ID^yl-Wn`TU)@AkFv ze1l^OPl!2u$1WJ+fYTh87Mtt=+ts_RDZv?fXv_u+&ixl^Le9CzAUM~5H!Y&Y*~8tv zd%+>G`^PWmhQI_I_F55I#>cqC}&oop^XT{*y6mPBM%V!agwHhEJlG zlB4jEY$yzDQd)LD&4+?8dAA_I7sY=RHx)KP`LUnYr%M!eF3JTIL;nuidSdI!5~bs9 zmV~`Z{b@PIQ0F!?`lI!@8Zt`SHEy1DZ!6M+A(AA*PSnNZ{Q)d>-Bn!s*rYXUSdO8r zZd1a++Va4E=;`>r+>F3g9Y7jJrIpK&4jB^49$x4YBL;gy#%tCh~OBjd>WvX#RHWT;J7i!T$m zdhu5loV1YYm3q01-JL8)=`Pj8OiRqajd*U`JJ0G0FkSZxn^HsRygqmx;c{~|7!xv7 z@SBKq95YP&%s1SgTNg{8n-&nWPi+hsC+7Wd5@lnSUMwj1PVSe->2R$^|)DQo$TU-K>1291wcL^pi< zNZ9Mmv_)`^bIch}oa@%!0EiV-f~-c;6pj_V*@jCkTtz5Vgb z02_VFoPjTQ7?g2Z#1aff+zFz{IEUioH^+%um~*!`f};pCXa}adykkZVCbq-T5JNCr z%eV(ItFodH+fBT%{HI{s2t67}es~z_@|;(dCB zA&6f`k28jVzn43q&gCH6`-2;;SF>%}AK!fu$&tcEwp`x_?SARtMqF}@zA3*u=y`!< zOHAQH-UIRFn>}z_G2B<&#{=XxqaWiU#?N8q{a4zk&qBar-(IIgm08%nl_Sns6c}L~ zM3;WwL7l3qsoX-1{#72V zFR-I!&+-Pp!8ku^f}37U0(|bzl?CmI;;V9OyOFRG~&AhG>mv7oCW^xfO8oIsL2}JlBE8RgvMZdYS1BBwFUmo5B}q27OhsUy-@G zg*54KS7E~=;2ilcxt?x;fAgx$BYU=PmrByCl7)Q|`Ji85Z$ek@ERC?o?MoHcetX9m zoq2;%V4s1L?;ZtO1~W2=a*XtE@$c>`q=>lMkooGq@A_M>r8t;XIOqMHX-c?Q0LEE@ zniN3!U3T9AfDgFl9Xd#%_VC&Dv7Uzh@cqv!xx=5bx;pf>t!XKVGf2NDnURAY9QjKR zSTUu&HR@Zg__>j8ghgt>=bidRnE^aS4L}g{l9~RGp-yMeC9h|vs85@^!)KJ|0mnNk0I7A~}UL0ygd^tJBg= zf)I1>wb9d3;k-TZIaxvXK(WM;GY{C+PS^Bo=k*(wAZvgS%(eK0IsRvt0GKx@{iXnt37 z^GPJxqFN(x&b;*=tc{2L`yOnb37jl~pnWAZSLB&`su5{)tEQ{TK4_-Dwz!IdhUP1x z{?ij9AV2u2@9%a-u(;=kVfIUb1lc@`{apVmeaP>`PVBPQ4pJ&fQ)i?J7dAuQ1~WY= z>rWaO0r(t7kUVRR0p|6HROT|Y!HShiwO!1GuH~R11~&}fKh)6c+jP$*jB4xbcv#mp zvbrL)d&?{6>d1+KPc?M~N*4;nsbV*Mi{0LYINvF|68C7tr}h@J9@9Y#dQTc5#kor? z`@CNz?p#jYGUdzSIBc$KE@D6D%7|1_*k^xxo+Lchdn1T?W{|X5DIU~x8o&j4^I8U^ zj--5!&i5KS4^7kB><-tef@_>AF!mHDhczdFAT@%I9ebLU)srrm0rDn@rZ1U8_pLN( zJt$Hg^zu?EjzbpyHikk2?mQOC3Qs&K+tJ@z=kSRYl)9y`U|aL{#@^UDHDTAD+_wz$ zjhSx>>T7W}87&7|{=R}KURBD(?jNXFE?GDS7A!@}q9aaK2*CBFF4lilQpNJ1=Eu~z z;$EF9!LJ$X{;4yUU)H=9GJ0zW2njgJK^w{%FLGiP>RFHYfhlK>muS6lDOlykplAWh zJ|98In~y*rJ**WR5`nkxV98$X`~d7?C;6I;9_JNx>Iiz_u{_qbjQOOJcPH20hn62I z__Kb<(X5TnsUK@2oPal=!2@xQuKa3nTx*7+7MGDo*lSj*U-ywZpG}xK>lmt6117Ml zjKy&_anY$ruH`Jx!pP9mC9Iok_?lQD<23l6`U9FUFI zlPZcK2P+fI_jilNcH*BXU-5aL876D3?L&A)dCE39sa(fRdHoC74fjbU+2ocT*0~n( zKe5OXb)Q)-WqQZjcAo3A?u))ko9vJNedMv|>N zM`6e5my>IWScHE4=9}EnW;b!w5|31NF*H;CLf9r^wor2@k&Fbh2E|zA)09{l6KSnC zV7F%8m}6??*>$R$tqt@EU=pME#i63lBBaO!@`~k5g&887lx>Obz8Q|&JCKWZKi@+B zjk8azO__3$er543+2+3*vs7f9Lg4$Rxl-baM53;UN9^aU`%8|avd`%4Zs(oiwN{)) zxt3_94@3A$Z~ImmcO;8*EKhp;-}~N4Cb6DKc@P!UZtAV}ZESQg1V)KxcvQ3$w>tfuMv@(E)nWsCv72`soKm5v?TTdJ0}Tu*bA-A z+P`5~6KxjsMN>GE>h-pzxSG>OCG;1C5Jzu}@))(u!(;qsCqtB~nKy#sKQ5U7#Zk7z z82ymhFHK@Eb8;!{t&fn2(l`U++T^L{x1ssCz?a;am zhwCMf(!#O9tO(@@`s9+DAFPm6cx2%Ha)Ol|rn=NsuXfsf@E+5Hl+k9JQxFgAuG&z8C%Q1kvbp>BCqXy&S12!jU-%9MvqLZKIqkn&? zc=@PHXD2hDeAeIt!>hM7{X5EQCLue%_^5pI9&&-Rrep5c>4nqB`_yrU)21gr5u5sh z7@a3;rm)XE|G?XGWjzBhaU_xhWbr4B+#UaYfZ&4I)M|=P;o!Xr!kPc{m#z;^)4Pzm zd`~~M*!e!{@uS`r^T?PRF@C7kGI<%QZ#!sJGIPi_vNYuP<=JCQFFVdam_--`E(u^i zW;K(pa0RtOnTe9XeH!BA$A?*Z#ilHcVaO^q$A(F^J$yZ*E~HauX$o>OFn;GhYcq;r z-v}GH2-XKrhM|;yi*IIbjZ7Y|OyfPzNkec-^6-nWulM9r1T(k38?es%Z;>cG-8*}N zi;z5_PP(^O7{h$C)yF>@4o3XmM3Jk@+i91x{$ zgq-B4@;r6Xoda){4GxuDZ*m;I9S#9(w5xt70GaRdG_FjT7ciy-bQ~WTz0`0A9q-@o zUBOC(5ck8UBLC7^nl?zeZQb`u!&vfwoH<<|4-BB%yk>`-Y2ub%yWsn6XjAudFetcdUTLC-eJv>hByJ(Hn*V- zJp^Kd`#ryMG0xe_9iy)@NMQ%NzRcfb8P7I*VknkKb?c*$3_??W+lNs@1$BXiUh#8- z{%r+v<34k)0Ot|#)M&h^cE=3j7n6;C4caOzeb-8St06z|py19V3@k6$sEKyjf~aht zEhgwPEm5vom#+IQn+@$@a zf01%t1CIi;=eL)YJ9YA^*w*r0sUy(aEd=a^1U_lo%{l#TK`}pCR(Ysycn}GRMj8#0 z^T7_XU-@6Wy~7sZbO4Wlgc0hJm_)5aq1b;ZXx=^;TN6qVogqyF%iDjH3M00uhC%1P z?$m({jLwOlF7jF4GT%=16CZ4hCGS~xmnBzr5zp@1TTR1wA)Pf023kHh7U3`H%^m73 z8k}7hn>?_Z?P$=ZW$a>nfh)rsG*3G&G^mnJ!HqoXt`F@ zhAE6y&zh53FH_T7ucgxaC9E^7@C<9q`&4}z_^P+{{^i8Ka9M;`sQHI$x>N~IlIYRl z`P6npeuU()NlGYTx0K7P;h*!_EB9vT>=^-+p6Ij4ZKDk7x9unImok)Zpm*<_l-%2X z-NWznZiX}D{4Ds`%-gcnik@EQHHKh9%a$$@_!L73s~lriFwd1>zcfC{LEdP`cFzu) zM{{0a$zRFEK!KeHUfE(~yUMS#81Ec8=uA7A2S6LwTnnk>$mujq;*VS=wy@~sVz=;k z%sYZgu2fy=YHKA7xA6h4f8K-ZaQPiy8$~TRq$c#yi z5vw`wT4R9fqzUFF{o3illPo?DDQKTYozM`Tl$!Dkmy5=~dr~_mwG<(3T}5=zDo21K zuRje7{82|5?|8!W3D?jVE>o`(H{rk7n)tjv3rg~7C%_JVw&oP(%ks;Xwu${RWLumc zZYuXB?BjVtxUU3~ef17ZNtm~J5sQiVydxpC(~iwTKYHzxjjOv2oz1GVZ{PMx5N9|C zb&#@K-e-NjoNm$PQs#fJbY<3wltud!g&Rm5`>D@&V7sF${SVIkQabve32Imh%$Bxl zvm6L)2QE;fIRen?7*y;`K~Xk ztdV+^O5*50TNFA{%`E(v^lBBfD*zuo56t>?m7-V!Tj&ie&>=!P!7&?+oWPDSrv$Or z8z{vBGe1$M7VxzrJ8>i(#{tnYX#Hg#x3tc{$r2qlv!@uC z7XO5Y`LGP@5(7YR+S<|%RKuNyXOAAfH|%U4B4@O?jHlJFN@8O6e6MYAc%+`G7P>v+ zIJatMCrmS)XNT7;5cT$CiE=>r=%P@q0 z4r@iEIq>L~%C@e|e!;(0p-eix1fFk`d)iUz*kaCp5Q5rbW(8$)337`ASuaSlZ~oX| z+*#DpKOJKMy9G-eoezq?5AUBvwV{5X8H_bcAO21`$T%n-;GQUe!^zsqU&3hr+mR(y zYwhfR$sX)||GjqUT<*YoY4o+e=n4}KScfbW(;CY>>@bd!7I!c$P(` zpYA=!_Cf)wWTm^MM*|AgCdS8vGXW$Rpd$&1RZPS3dpdb%7Q`<`dB*k=k zIPPN%yQ~5(x*do6l_%pldHBOa7kD>mxCXuIKVOtH8Apa|=jy zXY9dF;}wl-qN@W}T+eQgS@+=RTq)m5bBW7)Q{K@UR>*vv7B7Kw2Z4F4p;8|{-%SxN zr9?bKZND#kO`OTRc-FCdxKk=6-s%Ybqvr~}L?x{?mxlEY>RW7e>W~!#XMU6c+C~w} z{lq27-IPwlSdR&_q{0fsa(=5FrBFR2UM|_lI~>Eem$wk zbtu-W93J)LwXP&mNJ#7LaaLB=um9;}s=96S8!2|`0&FoPE*H%mFJg~0+d_v>% z-NjZf9Z)c+@NQHhvjc(7$un)OO^8Wxv`rm4&WOSpm$Brm$QgEXJX=a90d_&{aw)Wp z4W%xtE*6LRa2w$4E5s_wcyH{62o}v-igFEDE$Q+L{#5aYhX1EWT$5jRe*&}2(RK1j z&gHLZV)0!Iok>co`dE-xgN zQ@t=>BR3QQTsFIMKtW$xK(oVuD&$uM`3YH#)8a_k2Qx+eSkzmj+Vfe_kBKLlYNuok=_ z`P2ZP9lg)>J)uWBaW}dD#Vt2&M+zfiNsEmY-HUCp!Wyx3K7NEHVva{z9tC~~bx z`b-McD_;ysUE!l@7WwTII%+RJG5y(eTiX!$7sIfPJRnLF^dvOf7!=_H-APmleect7 z3E3WQ+I`pl^D6l*0s3UgQS=^3baBz<4^o7FbJ=Zlt>YLwq`5bw>mkF`RQ;+9XT05t zFf*FI;rC$?9O&^`(oi%1>uT@vz8CSac`7=R#UDSZoaR1P;Eh-EFnm@YQU|lnPL6Ui zd)c*s@|FMPbIZA2W9r~bL;?-v{j*3v8N=AhN_QWume(LH1jxj1Z1O0)NefM03L;2v zMbWL{cHiN38rlj&>>z8=$rcKi5x(elRZ~A19z>vCf6?prMbQG^X-t$=i?}!rSn-?59;*y6s-mPIJDqc4 zi&%4?DsLITRnM9TyN~#Ku&>!32=ut5s$lkLKp_T1HM0>^OXE)$jOnDN6+2$1NBa{gZt@FI{yrxU&h^d3_!u0=qxaLJot z8W38~6W^|Tv`qA5SKk$w^4BI}%Q^Xk#wd@s8EhqfvORx>G{@oo_GKdsYJroG&Jfz! zj6JaNC0m=}L?TDaEsumquIm=viab2o{9jDeO@HB;nB?*xKbk_DahdH-Y3j1%Tsripn>`I3Ae}nWn=h@J-yTT4-Ir~Swl1IM7qU<2cSv%u>N0j=41`T)D!*mjChA(&`ejR^kjS(xu9tX}z}|LlJIebQAgW z*!MH2kwsBXP3OnG=MHqQN_+Qa+F*}tT^)q`93b$d+r;Ol3^}(o3n7E!`A+Zuhi50U z#Lbp-?kjGIwIex-kSx*wa0kkzDxyRf4Q`tfALFDH39q^ul!7S^&TAbj7tlX>tQEAk zSl!=+y`hKK6D*e6K+9#$XwKg(lE14$>3N!;o{aUJt)NJ{?7`=zB<$0`L-wsmSI{B# zCo^k<21=h2$3!o*=OG|yL^!|?x#)cWYCPxE{JA_B4AGkY4(E)zKQUNdIqy^x%@2@P zytU?O2)Tw#8DN1&b+_`Ff#-v&X0rp_9(6Ms&#E(_iM3&2a zcvWB!hBJH}x2lq`_c>Onzu4p%_A~r!tEW^I;-3N(lqJX@6UC``Gd##Lc~2v0);_Zd zuEw_U>czo-v{HZBwgIilb7_SuIUkbZF=5a`6?%#}T=uHW++kG<|Hlsu5d4#bqo(*t zd`o|S|N7f8zpv*G=kD|mYgp@f^pC|?p7c^5AwdWILHOS5V1^#ztV|45!Y#D3`Bm-oluShA-;#*u0O!ztHMpJ}+&ss|Zv+Q5-gpXXnj3$G8{p zYJ=Z0N>%ClDD9`;8WUWK8ihHq;8jH;qC_#C?Qp+dQ?X37d6E+Pm9IX!KVRx;k0|tv zn9NrD$VBqc?{6|{lwx~K2z>o*k1tlj{+0q#EFC%v9(_mqz(o2DQJgoLDT&p2F4>7@ zhPs=(tV!v(C^#t;ZC42aEq(sJ{ zk$5P%5U7VW1{v?W$!J>cj6Kgr&7YNL9PPA7H+2CC?$1?+DYu(`5`)m%cLOJvexwG5 z>Eqf^ZQ3fYM&V)!XSMWT8{0RwyPYZocc!_^BkC*Yd{TwKw>P2WIs2Ex%Nl@%dy3fy zE|N7wm&i|MY|46Q#IH%R&zP>ssBSF!(0)4qVZQ2wt$}$q&5ZFKJ9omMOYfc0OgY*& z&Ot#Pd8X)y+ecYky!(|6GN9Z3CmuOZXry#?Unij%ps)ic`&q0(nViB#!tmwJ1gexf11O8c*MsJuz9*k zrdPk`FlwGCQ#UCWBZP_+lP3 z+gSd>+`&Qb(mpxU_Ru?}?oLuDBp&=LPb4o&KOwp!xj=?k1=jJx(EQL?CV$R4Q~+&B z*bx9F;N4g4uK5$cgaqG{Z#TwLLWt6hUD`1Aj#P#ff(*oLm(iMa0n^(a``*h1WQ zNj~1JMgtr9leNjDEtH-YT)#{k$n zd#w$>il9#y9UY|9KQ^!8Btrj^{`K*oW>yp%n%iP2)YZnDSa~<$T+|dJ)fPFK(po7^ zGX4}5`i^RgE(B$gN~t7nlD;YdK>ATdg@tR1yne4wF4I1f^E&K}4&~yUAPqYuiXC77 zdRV>gxb7zJIKe2=M@Q321BL{vB3eRR3>nOY5C(am&ndPegYy4eOdM9X9rxV|5cG$7 z6hcm3>m|e24UTOsu%ZcJ&Nt-XPDX=LEFwBIzNO7$*jZ6`ZSsM0=6h@M7=hr4!UQNAPN z3PoDk;jKqM@ctp?>#?-be$O>CB`{KwbmNdl@7{&|4{7FMr-n7TU*w7tD-b8OFVY0k z)jPIc&IMuA*JHxVz=ldlvX6f2P540%u?O62d5(Vyqmc=}<_7c@c1?Ik7*>wP!3?i? zG=|E^hLW~iXrA7~Cz!f@4|>J;KiP5FgUORw1J53U-A=6Kd50~Z9wbQ9sGHKzaNo1- zUHZ-P01)KupOh}jl@dmAYmT=cwaM4|@vk-Fx(yhQgyahqA@~2L8JR^K2(lf^3iCg1 zHSj zbP-Lz``q65p0)LXR7Mf}d}H}VUtCq3FaVbqWGtbCf^>Z*DDKRI^iU~I`ENNdJAH3n z4ubg#DQu(Xxv~~aqAC>PGCIKLLmGHM8XftKZ5Lb}?D3~)$5>Z-|1qYE2QVM#Cp#Q+ z^FX`6sWRYaV6gqw7QDAaq|s}`N(?t?j=}#7z=zdB6X-n9v(7Vi#2~4usqj}3g!UT1 zm|G7YBe-`nPPVckvh}!Q$(G_DeZUDRKAOmW=%f6X#&}sEH?2+Lx_{umUm0RGhGt(l z_wA8Srj_;g!!kT4hX*IJ)~&_DNdK$O5bINePX{`c{|el6mU)V)fWo{C=zhh05~=OjNe1MIWuJrRVeI$=xZ?D1kXwmu#Zea3Ya1wm2oq_^Cr`ox z;nMr!QPD#s$&;HYE!m2+yQ?J^Lw@JY;cr=4v)e#8=+vauTu$raC4=itb>6th2h&W0 z^sZhb5-7weS6sVn4?_gki0Bv5S56W^7Ri;E3I9W%8SsX{2TALFpZb?_lghBVi%j@T z2nzeGWCv^8xQ$lrsiNnBeCO+Wq{q4Hud({{Z1H$OGu6raw>AYGu1HzF8Pp;Oz7mlG z+hqyY;TT9{i;+tw4URp?3LW{+ZT1aP>{-cSoXlOZ+8rZA%=aw+>%*?lPq}c0lLK2> z>Z_w=yrH3?>gs9;(0!hDE#)Kw@zr1m*`B9mC~GYj=c{5P%=LiCHojihT{U@y=OYnU zkSZfK{dwZB=1t<8L^6ilCJt@&mz7U*da|n*xyY_Kz?b( z5!|IgTgeDNX%SbRD+D!jGd#u(VT`w$9@>UMgGKQSs9uUSx4?UGYc zr_rRHM6zXJQ&43Fqzcv7*XQeopArRPi7tDR{P7r*mI$K|#ug$zYCG4wX>_?436%N# zLC~+JVq)nc$-;!QZBl>>cU=^%M#{`7rQvx_(I9=MGDrz)^L+pQ%iZZIo%wp~@1wCV z3|2wu9~S>S9;!aV+NmFuYaYkWMK;dArSW|^QqwZIpiBAoO73zJekL0m#M8$qrm`i8}|wl*ni2L~?_Z_*NvXwYwX6CiZEH!j5gSMXBVPG8#JLcp{WyD*ad_UY}? zqpCd8>480TW$b;uDWdZ{IIo(Lz2L4$9a!A9)}DlIG0`JrQRlLA$iS%hTmH~bHb6M= z`fjs*JOGLUB#4RGA=(}P{!?_n5uFBp;c&pqjz}w7De0ty=i6c)Ww>)4DrhDDIgomj z4^psJ59x6aL<7;rjlN?#XL?ylfyJO>xx;0?*N8GJLn)&dD>6U?(TPZ$|GOJV(M%&k z7I5T}He-{}TeD?LAD9 z*Cf28I# zYCE4^V>${If8)~h03VSr;u-EGk25nz!fa>|0}uH47rp_`ZOWm5d7tO8?ZNr^T3S$Y zfOQVvZ|J`oEGt;GKkRXD$ThEIfHKJ<2wZ#P7r;0cc;vBO;S$Kzb{gX{eHB#E*vO8x zjdOCp_~ky8T`3YWN|24Q=WSYATAuu?2cTIwjrHle z4m!SeI7TUVKQmSITykpi0$%^A+#aBH{X0G;=eT8KXmg5uAw;!{yMvEC&ar3xur_fU zyeS}IW=F>yWQ`OHP5%j(nm2cYy%A+{O0@DD7$DOVbXXg&h9vSTKGl43FaFj4-Jx7ycEg^5`KK37W)xY6P z@7WUT;~GFu)Q;FfpIoW)&?r#-(NJ~l9$^eV!~kC@BRY15>_7T?m`*_>SSM5$+I#%g z*cZQ0>ydfdAlua`3+iEBZ*Wmv5;9>!)Tfn>{VsL+aOl&z^{KQUB>ZtZC4f{d0*3pQ z@WFg?rRZ8NaB<}MeEEf;nKVJlwn0YBu{uzAX8UKme-l8^!oBLNDlHYBhu-M! zp{56sa?KT&r5veERVf~>Yq2!@_}a@&4{g`VJZ|PeC~TP5dglokj$`59KGs7ag@#uz zc-n`JihAqoP#cFYMw|sF$mv9h=^fG_n!yq!^dwln0uD_GUOP4X+KCxlXPcT z{pgh5{}a}ywKis!VHR%7^Rnx*(3G%CZ^4Dv5_48LzGo%vRSE zN{%m(9PkoMC*?+*K6lIXy#|*;?IHD4XOiXX@S`0_5v|@1LN+rt_V==v&;LTy+-pOz z^3U0eU}&!+SjT$~d^ZV14gRBZgO`uT31_<=qNq4)qxJ`pYKjJzdurInE4}hE-IZunNAMz$j<5(5y02G&&(y@-H1C;kQhngEMuFre5=vFC>G_Q|Yv*$S&`v671lc5g?qF5gs>BxqHZ$<&`&F=0F*(?#QzTH8 zYYG2C*qUsUyg_IM$9+qs-!f|f<0nESJ98-RN-Gczj*p3*c%{C91vMW^_r^mxs^#(m z`dRymiXjm}{wh`$1I}J2XNo)i4G8+p*b2Rcwnm~#x+n2BiHCCSihG=>Bf|FG71*{vNqX|%a-7GC_9 zLy_5=rl=Q7SrMlj=gJ%k%YD9_DVVM7@KtF0UJFBQe(`9@JE$!_8R3 zKXrqCsNIA6cJMnib?8-gZUu|XOw$mzAuhTMuEy`p$S+TG`kk2{e;I1>hhrT}GdYDd zGFtd?xr#e>Qwe1}X#ke-3w=LA5Or~Uh>fH>^=(^ranI4=koN|;+OG&yzX+SMwLmcpq7;JL#63xC%? z9ZT)s62Y3!9Y12B-2(XM@D${qKHHcubNU2iRCWLB$Cg^xM>5Fa)tRNrD95h@8K))^ z?G1^|yG@0!FW_tUr~i^Trb~DpSSy9Yh~wAk@ZWWB-Z?o!?6Bxrk})`Csx1MPbH-UZ z389GY$kf$}lf3UV=MgZfe=sF!fDp^b`q0;hdh1d#;td4(;anf2Ez}TUA}O_SX(J-6GvB-3^-#DFG=->29RigfvJAY*9*5 z5l|Y5O-my!U4nFX9iDUE|KR;P)-~6)#+NzfT=y9F&$Z1-%cUMKlWRA)`bJyna|#E6 zJ%t7;JshzW_~%BoCv$r6Qibni^Zlp(l?(Z*CS9Ze+PJa*gSTeMeqp-ro6nex7P+^_ zs?}DLwj%}#W>w%Ac*Nr*M&L4hwt2Wc$E`{sZ+W8w8+-n-thl8K6VJH_%>!?Wwhep7E+i?539R9PvyA+HFEqwULp)kY} z4j;JWZQ={2+q3K>qg4&O5j!z>)4sY$izq@KOzkDzl*Nb_*}OR^du2Ku?_R#cb2mC0 zaxRJl1?9+)<`iqM8Z{(SC&SFEDbW!w@2L0b3Xu%Els4*UEzin4_qIVChr~wPq=S_c zbAF#jmq>zlPf5sVzjabaziB_q6aCCn^sHwyu26ORxD#y|LlH8y9r{%`Fu1F2;zKw45@~~H`w<*27 z8+TY$#G%f8C+k=3e@817Y;@BC9ZA8AETrdA-}oTt9b*FE__WUc5is?-727UFwSI{J2eGcX9kzaWL16nlrhr+&Hqr{_aih zS1%3SJr1&$x7%~41`_D=S$NxK!u7wZKvYz2d{QrqAApRP>u)T(IjHuoAOElX;z1ps z)|UytgTIJmR%GXmxk;S%_$+JTYiab(qvx-B0k)m^IG-^LXV)ksQOe#F0;8+aMGw}6 zfDtMCCN_K*b{T5CbOMLMNy_~0+qiy_)%Xv{O6j8Aj>NlWKWFgiYh!V$#Va%XaC(F4DHCJAZs!iU_;ZD+6H`)XmrIa)Gqup~#^fw|Dd4 zYLUBV<@GMwWgfQByDMho;fU;(aKbh{30{wb#T?Nd4osCPl2QQ z%2hl0Ri`x_20;ut_yytlSZQQP^5ASX{Rd^ z*g|?zocb~j=2h8t>5pYDSvp=ry=O%XTDgN0Bd#5$RFIpRsNv1(vb>Q@{%R?+&xnJUr|LGfkU~JRvGLab%B;n zf}ZZv6>p~mDS2lYhl`@H7#CU`U%#_KT6wk?0IhgSgxCYPmt7E6VL|>;7kJmEPF+yk z)tXl`j*OWe%S-d-DbwFlt98+PLl!o@UvWDV7hQ8O37m_@V1@j+Q@LB;#e;%HGijM+ zY%cnop&J(uF^&Una!%`Y3a{kV_=03OZi|6CB?OSPq9u7)h<5|R4kgy|D;uE>O%@%_ zd3ktG0E)p;a~cN_^qJHm;Z#rYkj-Dx#LkjHa))r|B}X)a^vr+H-{rpDku4iFRU7M7 zC)XWBEs(XA;#6{U&$QIwnh7_*f&JGmQ}pT~;zzwX`swxyA?2;|L0v=A=J!ftYxR~X zSJ@Ss`Q-P<-!D)d_q3Dd%G{1#grZ|7C&zq8;v(|D4xaaKv^CW@M$+>6(M`(Jj4G#E zg@-WTT+D*@X4~e=t`>M(+%2W2cr9DTQOWp&LPRr=ZF|JTz${W8oFn<>Wp2RlPtlf) zDlwWo0YPRWiPWqwbH+S>iot#^q)k(2zMGS~Nl!|+_`5@LuI9VqV_AOJpYniCvG%5O zqVWPg)gzrTEATFK5(|OV1OLX4FlHZGrT(>Bx-Y^=*T?bG!r@{mx?4&nuOnDuPvGsy zK>bfXk+VRNWtGW5Mxt=(n?nbosLk-Y2J@eL1t1HdHQ;upWCe;4`6@6SI(@A&4Fxvd zW+@#qLEC@#w>;6i+4J$=S4Ug-bKoGvU=j_cyI85;K?5tUpeFKERh>0kV3aM(deU+) zf^TOV@QfQ9RE~WRiJo}ed)LzT>dgQP_Z=x&%QYc3R0-QqZx(3%;21UFgfx#|NJ?Hg z5-Na&O|N6-zRuBbL|BPn5OqCjj;wWr+qy9@<0=(p5RZ!cUj*^A2D$Lf{4Cuts*L z42qIk*k`B7rSFh&N*K8K>eG?;KHGZRk#A*_efDrSq@~m>#GHm^?M87kS!ajJ6E%L3 z`Sse%dM$Ga|FKgfTn*d6ISvHHqNy48Lgb1$P2+Rqc5+zSY)_6V^o;b-75#mp>M^T% zB_D?fnZY$*EeK{6 z;>!!uIpst&fk7sSCt>tXrU=g7zhAtn&*3&$#*=rWG*ZRnZDmwJ4c7H9P83IZ7MQ$5 zY&Y>_?i1V0d=3LoJSkPlHXklCHM9N%Q}6X>G1-})ll=bDM(zMnhgi%;v_DxPlb&pg z7^?*Vhajc}Elou7O(pbaC7NoRvnUd_H}98t*h%U-s+3E!2JwGg2wU>Lc#(yqdYf2K z(EccSF-SS_(gd;N=9dP2!HviQdrP9>u=A>fz48m4Y;7)!GL;9(V<(uGd^tJu(x&5V z7Je#VDYCKIE9NzRB|2e{j`O4~SGFedjA8KbX2L-IBdtkxfvPnxWA6y2d`#!DJ$`iCX?yoOWRjhfUI@Z~?2kLQ8JI zVUUT`-;vI_;oPq=Mq7r1%huP0`e3!m?vdV++LY5@->4& z)SmEpu@8{~!GkG0QXxtsOwBtPEj#<9Y!s3VI&FSY=5ZckUL-G43OSGQ35v3)296HM zdwh!a4GQ-zeI|M2b6T|9yzqtwFbRjg1Xpc=htYBV!q;D*Dw4zq$j>-i?={n)guG^{ZC@tn*$ zZ1T7Ry}@DW5!R&4+gzVx@9Q7&Sgi}eVTo`gWmg^igiy4V6A6|7eZ^1}f3URY7nc$U zQvB^JQ#=sxd({(if4)+3CY~YK`j&E3{hut`pmPLD;;LB}AA|SO=R6A=34!Lrn=S%= zKiUpdvA|KcN<*Z}FMZ1fj9$S8?1z;P+LYxZFdJ2Q#y`b;Qr&h|s+I8SV50gEXz#M@ zM)M*h?9P4qd$4;;DF%Vg;<*__SU=c`q<-!$FidH1zS%@XTcwsGWU7HHNTM6nl2mkj zzqaR^S3?t7ch9J0s?*UOR2Tnsoj{Vds>aOHU*ff6-+D+ghaZKFQRJ%gV%u-JGikZE=V8?r8@XMnD;jW8#Sa4BN`O#@P98 zTk-v!@8sV0_^9ZIV~<>Z@bMQmpDNqfapEWRyX@PFmQ4HpF?98rB^qb>iGZ0G4P2yv z2Xp6QZCBah7|QezVVML=*SDNvlXd2qxM8% zMU=OTuKgYTU;o7y+_kjR?VF**ReR~Jqwz!Roe|0iPsMdzyA+(P3W;wzh-cIi}e zixZUoO0;&VhOomh*kWX^koKm`?HOsngA|*#QXT~J#QnUrYJc!uO$7yzLd&a9gH1bL zzu<7L-Q6iGPvgwV5p1_E&dpX6yIh!169STbuVShMoCIJ;WHiO_YRV0c&5B6n3x?le zVX#%frz?d(z{lHtxgHY$%k)#3|5KdQqdj64bY$sViB>lP~7-aDyr2Ia8B zv?J0E*8FFBW5wd|_$9<*V6F=Z7Zd{O#|4+EnZ%8>aiXNBdld_2m}Siqia3?J14^7s ztWI0FO-#ou4o728W|a|$s^2dnobuDZnAVtf(S2->^=~vs)WvVF?6HkuTsG?c&=O%^ z+RLnG580^IsV@5%sAWF!f^mYt>PB3zO)Mf}94EY7N^PSr$dTuLA5?(;vU-lPF%Z)| zB+`ApDRk2jUM{jXt! z_-r*{O*;i#GAnW#Kt)8{RRMk}n#SQHn5dna#9Y;Zxs0oCFc6GevUEn9(J5|Hyxie+ zeYuw(`tZkK!#bU#bLr_P3Ld6Fsu)?=QEvtekdxW( zjA4rRy5(B}W_a`O+nMs`9qpr9ylkH(8zV5j-l3%{r0WJBjTs65B|~j6{es_h;#785 zK&BSXX{6Qu9iQ!31SCps7+M+n>D@=BZR1h}c>FA_$|wx~Y?f_2(qQ{d%Y8M{7Rr1;`JI#u5CTad1tH*fe>!4k!MtmzR$9*faD- zeEO7n%3ab}&y`zDWDP`dU4NYJBx1Hhov5irWv0flZ8F3CdX-A@+kF*hg(3oucme$w zCn+FQM<1VdTHrcJAizhxwy7f=GKf+P0nAwKLJAprE!3__9G>1TQpLm-($5}el4hJS zP#J7wPX7E*|3&IVU`uURMh~4&#!7M@v6i|-OX|8+QU1E91*0=0NY#z|E=)iM!zlyY zr73fRs`{Op`%fG|Fz@sU#s;3cn@KFI;+rsJGp;M}>BL3flrkpPr!Qbjyi#*go4I9t z^Fft+hj!EgwZF9u3Z>@WO9<66Z+ERD=Uf&Zu+=)uk*}iA9)}j*=RD2x_|jO` z-J(L+GEGL%=}SKT+btf>=pG8R2ZiWPX&tJX*|~NUh6wJva8%;&P&S1C%Vu(H{(Dis z@ygTjCiwep$G?<|sh_|;-nnUjCO8L=;-+{KjN?nHG+l3AU~~QvZn#m~Lea790kWc@ z5H-34Hh|NA*|QZ*KU6t%CbYmwyzui&VrGcq{!ffTG+%?ci%e{KkSJFSB31cy5D|%J zy>oPA&yGuIZ%2AmrB&&#MwJapbib8FYRdgW&~o5Fsg}ZFxh$=R0~hL5q?|VvVYx&r zEidpRxP9$n!2Owo!E2i)%0f5``V5sYX?-~5>u~IWrpeL0E{uu1-W zoBDH6q};6DYgwK74NQy@7VzY*RPTKI>Mow#;^+(ZtL2)Ag&a#B+<6Nk*D9pfch&sd z;jc8UxF6$Zj_{OvANHECx^}}3!U>T=1gfHy{+84Em4zpH644x)$MS_7ws2p!}!6 zKJrw*SyZ`eo;>Z4TMPM9gtRn`(Kkmpky{o!Q6!^VM5r6v!0b|AfxjOMuw-QY(+gXu) zT4~mfE^?ZxVS3IKdqe6sIpzE_SsZPl6Swg)lMDnKV1J=Cs zIqRQV{nYGN0ew4yG(e^5;f{oQoMztvs29&-N?4p{mpPo()biz10(EoDJ-E=&5EqZl zzP@q4-6xk~H&J&DHSHz2U-pYpUf*iIuBkkPG`bKnz6N*+_(K)&U<%%m3vj!Ks&(VqzjmC~;UG^u znG_H++i6}U;BOpHE)8XV_YW{7{NP`a<2T1IwE$69h^cH|p9~uK$9dXoup?x{kImpc zxck#-f$ME3Pe&q{W0whq`#PkM5)4G$Vi#DtnT1==6*DGo?+S)k~$n1llqd8rM;-8iJ^0{e#F0K0z}H;SB8ICzDH z+fcYXBQeehv?D)Wi)6yIS8!lUfH4eKbh@{SvQ`JXtFK=pJ|sUUcww7oNuVnYIRq43 ze1g-^U08{5RIIkLQZvA}W5(WH2l2y!82H{B)%K(78>tc{e<7kb+ujiX8@|#i0(XREoKA;hEEVp@s~ygVZIM90a6_f* z&Cho3lUN7IXTb~~l?Z`wx}R-nN0{&h58l+AYz&q3CtbOzixgtE4Y5xgTSSsvB>{I^ zz#XDoMe>$?c*hJl)YjcNKWhR{_1P8v7;zmR%zf1x4=s*(ZpX8esSCn>rq5iLx`;#P zR*(Fy0cYN0@o}JTrAaj+NUV_-A^Z;rVrI+X0Z#eBnfSA*{|Tx-?kuOsP;}~F2Xbc zi*;X_`oEt(zY7baO~cVfMJVH-_StoK9$=%1=l}zK5F36u3xRjm%U87@@RKmneo9x* zA_m!#Ts}s2gebhg>I(ujc7q`ldW(FOE?WEQP}>hPWVLo}Y(hvIZ=;aoz4XO34G7*p zeqojd+Z7~NdY5{SC=%kDTkWJ-D;y6hN~< zx=fKb{)H;i`xYBVuXF$nV(l77VDF*ZQM8Tqx!;M-8&lF-L$sT8hL2u+GO*&iKLC*y zl?bB9ek+U*&|!`k1||EUk=2kIkJ05heN-9Ax~>>{Uhj?YSEjKp%+6P@tEyS_gh46v z3Mmga?z_tTc?XgUf9TGwg=r+wZrWE5_QP72F4U>j;BPR`98E|`12gFdreFP*XJXe zcxHcjdD_2anwheFx`M1=XD|ls9O?H{^d;^Kob#5HC8&iiUyyn>HUP}$d77DH6oF_5 zQ}RG3I^`4H?f+5@b~me;6~BAAl!)IfU4aTgWINcrdnt=dDfwHn&TI@XC+FKSp#~jz ztOha8o*b{f#kruD5^Ci5L*dJ>lJ8-1EY~S~hDshmhhuTG8_k=8<*Udbk{op#=E8wb z1dGEx58i`I;hb*I-ZXa`ONn@>ht4dh^K!|<^UyK&(UX&8Bd+Pv2j_c45NJt#LwEco zgaqT`Emp;Ej6r9D<0gnib9Wbfvzg)o4Rj#tR}{7~b_-qZf-Tb*3kKbdQ-<3-Rv4X+ z`L97f9|9Waz@ryZx4K|KpF$L0Q1&H}%}2|aX8+Riv{Hg{aBHW(3<{ z!T2719vvJoSCBK5WZD9;9u}lY?d`-f*GvbXzRq|v653pU_eC83jmVg}&kta_ykAM^ z@AN62<9~yW$1-t@Vg>TC+_<$0kHJiJ0_Yo{U)DH&U=*>l3CQ_rwoI`G7LcK^&ijKg zLXWF{T{Q9OYw(av4mAhFMkU@im`3{0U;?%J+n$`+tZ7~63R}DUy4TIr5wu_Yc`f`q=YqZyO@Ho)P z^Hyz_c#la32pB;XY!Qlw_yPDjL-==+8#06=G0sdq#u~8;Jz$|Q$l5ZiH6=@j_D#^kv5@b8tI19lOi-4OJi+{x zbVD$svOM-G`~(+^yg9^`_GZnH7rWn`Chn6dRYK5tuDh9Sg#$cVl|T@NP%SR-lvY>S zs~PQ9NB5jj?KzYTSe_lqiLURo#6MwlKl}5&sdf!g19ndp2k?qJpuRFo+?PUg^O*g^ z(LB8i0*7l%L4vQ|*0BJ9H%^cM?-nOMt>QL!i-q0H>~Y7}YrzKg6j;{Yh!8FW&+Nx> z*!E<|)@;w7NnfGJdqythRAXo7C&l3dNp^o15UgMW3vKC%i^hz0BzwF7r%Tq@w>FCp zPo(g}o~LaCV`q8Ixdc&_fJ2i&m7#%j!^#Q zB!_}m@9Q`Ldm@I|MosEzOG(|~?oKH+#9#ZvB(|J3Zg@6jbq(8pCQ|mI3b{&r-mYaQ~NN2Mn(RY;o~JNJ&e`Q zf8%TNtyGnxk-tjV3x)9QNvZeWo!*X9iF(FFssj2PAYUg!VUx7y|FC8`3((z_C1~BT z$ERS382ek*UJOZ4R1Y;@btYX<0Y2o~p0|2Xp^sRq*}vpJAMuZG*Z-lneiT#)mb7tr}Elr z2li0;f_iCe={FAbsUrB+yb8F5sUL_ZeEu3 zG|`39N8M1%ko!rMrAhN=%{QYG#{Pi{c}6j#c9yxyo?c2-1Ux%G8_dk>7x2{sEHB6} zA&@T(TMXVot_HJFKyuzp+Awn%OL#!)?FuR{;fLt+5$7LXgFAoG3#J(cr5aUN(M7x9 zaLDK$#VBF86D3%D5*#YkP#R|1047`R+O+Zerc%=zMtQM5E;cvPUM}MPWTGL`xbHn9s_M!kH-w(yDAK-;AxfK+i zj50f@v>A)}OFn8h)PCHcYSuRx{T%g9wq5T{4710)^R#aq9|VDc3u9zc{b7qDb`KYi z^(SZa>WN(TE6}+8pdR_$i137$mkGCdt+C-0-GVT>RE&EawI~cLZLJ9|k1PC!PNN&?# z8ocUgE%~&Z({-88vRd<+RD(d6OPb;rdG*d8Yie=_+=>w4tLU$bVSXepLVZ3yrJ*AG zd>jBi!H#)>^tKhk{xa_K&N6g~*u%I62S|Fuh2JTMgCVcbi4J@ejaZSj7 z^#6YT`iLR#c6vKOisV|`bUQq6;vODLU<=1xL2;zd&r4-nwUGg0PDA$v^TPl0mp{-e_v|>1*jMmD+YvXC81sg+P zTjBmA--7N*S<<}3P!S*Bdus}XrV3tsGydF2_B2^C^9vpPf@?@?vI*k&WrPjgA9<^{ z1CEF9RYUEz5Ye@nXHf=8Sv^)4koWVYPxd8qUC7sMp}{Li>D^A~`@toV_kg=bI+^_- zCi83D@R$Zc_C9(x(wQncc?seCd}uLT&)^>#D+y9{r1T@lfne(8i%+=iZcq60){S9b znS*$H5AXG$3=4J7jRhXx#8|r2p3SS+WVZBeF{F~P;K6CAuQXWkEcUb(b)*FK$>3}~ zN|$}axCqw#e?d-G97zoF9+@)~(mKt#stDHq_%jsZa*GBm@Ozv81Fl38;9HBK{@)WK zh8t`BS7G=WA&CDLH5t2wumyI0O)^d452gKI0*k_Gi(Bi#3<))?9K%oKe?#&}=ZDn$ z?$G~b)Y#-au>2RD%VAn?iVW`Tppq(J}WDlDVg zz@T{F8W;Jdq)@t~wXNkajOSCf(enH4ADh?&^`&1+{*TcUiGzQv%3MfmKX7h7 ip|le5C_)rJps7ps(rT;xHhqK+0ji3c3Ken|;r|2T7#y$w literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable/ic_group_80.xml b/app/src/main/res/drawable/ic_group_80.xml new file mode 100644 index 0000000000..700ca216ea --- /dev/null +++ b/app/src/main/res/drawable/ic_group_80.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_group_outline_40.xml b/app/src/main/res/drawable/ic_group_outline_34.xml similarity index 92% rename from app/src/main/res/drawable/ic_group_outline_40.xml rename to app/src/main/res/drawable/ic_group_outline_34.xml index ea3278892c..02758fb42f 100644 --- a/app/src/main/res/drawable/ic_group_outline_40.xml +++ b/app/src/main/res/drawable/ic_group_outline_34.xml @@ -1,6 +1,6 @@ + + diff --git a/app/src/main/res/drawable/ic_note_24.xml b/app/src/main/res/drawable/ic_note_24.xml new file mode 100644 index 0000000000..c4ed135726 --- /dev/null +++ b/app/src/main/res/drawable/ic_note_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_note_34.xml b/app/src/main/res/drawable/ic_note_34.xml new file mode 100644 index 0000000000..18696ac8f8 --- /dev/null +++ b/app/src/main/res/drawable/ic_note_34.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_note_80.xml b/app/src/main/res/drawable/ic_note_80.xml new file mode 100644 index 0000000000..b230d7e0fc --- /dev/null +++ b/app/src/main/res/drawable/ic_note_80.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_profile_80.xml b/app/src/main/res/drawable/ic_profile_80.xml new file mode 100644 index 0000000000..da93d76201 --- /dev/null +++ b/app/src/main/res/drawable/ic_profile_80.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_profile_outline_48.xml b/app/src/main/res/drawable/ic_profile_outline_48.xml new file mode 100644 index 0000000000..b716cf4fe3 --- /dev/null +++ b/app/src/main/res/drawable/ic_profile_outline_48.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/conversation_activity.xml b/app/src/main/res/layout/conversation_activity.xml index 3acb531eca..9f7e653191 100644 --- a/app/src/main/res/layout/conversation_activity.xml +++ b/app/src/main/res/layout/conversation_activity.xml @@ -77,6 +77,7 @@ android:layout="@layout/conversation_activity_attachment_editor_stub" /> + +