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 37fd3ee077..0000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_note_to_self.webp and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_note_to_self.webp b/app/src/main/res/drawable-mdpi/ic_note_to_self.webp deleted file mode 100644 index 2b9a32c670..0000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_note_to_self.webp and /dev/null differ 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 0000000000..e741ffe5f3 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/message_requests_megaphone.png differ 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 b0d2288b01..0000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_note_to_self.webp and /dev/null differ 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 0000000000..85d40a093e Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/message_requests_megaphone.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_note_to_self.webp b/app/src/main/res/drawable-xxhdpi/ic_note_to_self.webp deleted file mode 100644 index 7e8cba641b..0000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_note_to_self.webp and /dev/null differ 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 0000000000..73897db4ee Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/message_requests_megaphone.png differ 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 7046e0ea8a..0000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_note_to_self.webp and /dev/null differ 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 0000000000..af5bdc91d2 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/message_requests_megaphone.png differ 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" /> + +