Improve message requests, add megaphone.

master
Alex Hart 2020-02-19 18:08:34 -04:00 committed by Greyson Parrelli
parent dc689d325b
commit 9e5f64c431
83 changed files with 2406 additions and 735 deletions

View File

@ -442,6 +442,11 @@
</intent-filter>
</activity>
<activity android:name=".messagerequests.MessageRequestMegaphoneActivity"
android:theme="@style/TextSecure.LightRegistrationTheme"
android:windowSoftInputMode="adjustResize"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".contactshare.ContactShareEditActivity"
android:theme="@style/TextSecure.LightTheme"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>

View File

@ -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));
}

View File

@ -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);
}
}

View File

@ -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);

View File

@ -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<OutgoingTextMessage, Void, Long>() {
@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<Uri, Long> 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<IdentityRecord> 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));
}
}

View File

@ -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);
}
}
}

View File

@ -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<String> 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;

View File

@ -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) {

View File

@ -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<Cursor>,
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);

View File

@ -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<Long> 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) {

View File

@ -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<VH extends RecyclerView.ViewHold
private @Nullable View footer;
private static class HeaderFooterViewHolder extends RecyclerView.ViewHolder {
public HeaderFooterViewHolder(View itemView) {
private ViewGroup container;
HeaderFooterViewHolder(@NonNull View itemView) {
super(itemView);
this.container = (ViewGroup) itemView;
}
void bind(@Nullable View view) {
unbind();
if (view != null) {
container.addView(view);
}
}
void unbind() {
container.removeAllViews();
}
}
@ -135,6 +153,8 @@ public abstract class CursorRecyclerViewAdapter<VH extends RecyclerView.ViewHold
public final void onViewRecycled(@NonNull ViewHolder holder) {
if (!(holder instanceof HeaderFooterViewHolder)) {
onItemViewRecycled((VH)holder);
} else {
((HeaderFooterViewHolder) holder).unbind();
}
}
@ -143,9 +163,11 @@ public abstract class CursorRecyclerViewAdapter<VH extends RecyclerView.ViewHold
@Override
public @NonNull final ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
switch (viewType) {
case HEADER_TYPE: return new HeaderFooterViewHolder(header);
case FOOTER_TYPE: return new HeaderFooterViewHolder(footer);
default: return onCreateItemViewHolder(parent, viewType);
case HEADER_TYPE:
case FOOTER_TYPE:
return new HeaderFooterViewHolder(LayoutInflater.from(context).inflate(R.layout.cursor_adapter_header_footer_view, parent, false));
default:
return onCreateItemViewHolder(parent, viewType);
}
}
@ -154,7 +176,11 @@ public abstract class CursorRecyclerViewAdapter<VH extends RecyclerView.ViewHold
@SuppressWarnings("unchecked")
@Override
public final void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) {
if (!isHeaderPosition(position) && !isFooterPosition(position)) {
if (isHeaderPosition(position)) {
((HeaderFooterViewHolder) viewHolder).bind(header);
} else if (isFooterPosition(position)) {
((HeaderFooterViewHolder) viewHolder).bind(footer);
} else {
if (isFastAccessPosition(position)) onBindFastAccessItemViewHolder((VH)viewHolder, position);
else onBindItemViewHolder((VH)viewHolder, getCursorAtPositionOrThrow(position));
}

View File

@ -165,12 +165,14 @@ public class GroupDatabase extends Database {
public List<String> 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<String> 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<String> members = Util.split(cursor.getString(cursor.getColumnIndexOrThrow(MEMBERS)), ",");

View File

@ -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;
}

View File

@ -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));
}
}

View File

@ -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<RecipientId, Long> 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<RecipientId, Long> 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);

View File

@ -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));
}
}

View File

@ -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;
}
}
}

View File

@ -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<Long, Boolean> 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);
}
}

View File

@ -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;
}
}

View File

@ -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<String, Boolean> booleans = new HashMap<>();
private final Map<String, boolean[]> 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;

View File

@ -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;

View File

@ -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<Long> 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;
}
}
}

View File

@ -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<JobMigration> getJobMigrations(@NonNull Application application) {
return Arrays.asList(new RecipientIdJobMigration(application),
new RecipientIdFollowUpJobMigration(),
new RecipientIdFollowUpJobMigration2());
new RecipientIdFollowUpJobMigration2(),
new SendReadReceiptsJobMigration(DatabaseFactory.getMmsSmsDatabase(application)));
}
}

View File

@ -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<RecipientId> target;
if (filterRecipient != null) target = Collections.singletonList(Recipient.resolved(filterRecipient).getId());

View File

@ -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();

View File

@ -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();

View File

@ -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<Long> messageIds;
private long timestamp;
public SendReadReceiptJob(@NonNull RecipientId recipientId, List<Long> messageIds) {
public SendReadReceiptJob(long threadId, @NonNull RecipientId recipientId, List<Long> 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<Long> 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<Long> 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);
}
}
}

View File

@ -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;

View File

@ -156,7 +156,7 @@ public class Megaphone {
}
enum Style {
REACTIONS, BASIC, FULLSCREEN
REACTIONS, BASIC, FULLSCREEN, POPUP
}
public interface EventListener {

View File

@ -50,6 +50,7 @@ public class MegaphoneRepository {
public void onFirstEverAppLaunch() {
executor.execute(() -> {
database.markFinished(Event.REACTIONS);
database.markFinished(Event.MESSAGE_REQUESTS);
resetDatabaseCache();
});
}

View File

@ -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;
}
}

View File

@ -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<Event, MegaphoneSchedule>() {{
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;

View File

@ -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()));
}
}
}

View File

@ -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<String> 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 "<b>" + target + "</b>";
}
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());
}
}

View File

@ -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<String> groups;
public final int memberCount;
public MessageRequestFragmentState(@NonNull MessageRequestState messageRequestState,
@Nullable MessageRecord messageRecord,
@Nullable Recipient recipient,
@Nullable List<String> 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<String> 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 + "]";
}
}

View File

@ -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<MessageRequestFragmentState> 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<MessageRequestFragmentState> 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<MessageRequestFragmentState, MessageRequestFragmentState> 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 extends ViewModel> T create(@NonNull Class<T> modelClass) {
return (T) new MessageRequestFragmentViewModel(new MessageRequestFragmentRepository(context, recipientId, threadId));
}
}
}

View File

@ -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);
}
}

View File

@ -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<MessageRecord> 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<List<String>> onGroupsLoaded) {
public void getGroups(@NonNull RecipientId recipientId, @NonNull Consumer<List<String>> onGroupsLoaded) {
SimpleTask.run(() -> {
GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context);
return groupDatabase.getGroupNamesContainingMember(recipientId);
}, onGroupsLoaded::accept);
}
public void getMemberCount(@NonNull Consumer<Integer> onMemberCountLoaded) {
public void getMemberCount(@NonNull RecipientId recipientId, @NonNull Consumer<Integer> onMemberCountLoaded) {
SimpleTask.run(() -> {
GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context);
Optional<GroupDatabase.GroupRecord> 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<Boolean> 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<MessagingDatabase.MarkedMessageInfo> 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);

View File

@ -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> status = new SingleLiveEvent<>();
private final MutableLiveData<Recipient> recipient = new MutableLiveData<>();
private final MutableLiveData<List<String>> groups = new MutableLiveData<>(Collections.emptyList());
private final MutableLiveData<Integer> memberCount = new MutableLiveData<>(0);
private final MutableLiveData<Boolean> shouldDisplayMessageRequest = new MutableLiveData<>();
private final LiveData<RecipientInfo> 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<Boolean> getShouldDisplayMessageRequest() {
return shouldDisplayMessageRequest;
}
public LiveData<Recipient> getRecipient() {
return recipient;
}
public LiveData<RecipientInfo> getRecipientInfo() {
return recipientInfo;
}
public LiveData<Status> 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 extends ViewModel> T create(@NonNull Class<T> 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<String> sharedGroups;
private RecipientInfo(@Nullable Recipient recipient, @Nullable Integer groupMemberCount, @Nullable List<String> 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<String> getSharedGroups() {
return sharedGroups;
}
}
public enum Status {
BLOCKED,
DELETED,
ACCEPTED
}
}

View File

@ -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);
}
}

View File

@ -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<RecipientId, List<SyncMessageId>> recipientIdMap = Stream.of(markedReadMessages)
.map(MarkedMessageInfo::getSyncMessageId)
.collect(Collectors.groupingBy(SyncMessageId::getRecipientId));
Map<Long, List<MarkedMessageInfo>> threadToInfo = Stream.of(markedReadMessages)
.collect(Collectors.groupingBy(MarkedMessageInfo::getThreadId));
for (Map.Entry<RecipientId, List<SyncMessageId>> entry : recipientIdMap.entrySet()) {
List<Long> timestamps = Stream.of(entry.getValue()).map(SyncMessageId::getTimetamp).toList();
Stream.of(threadToInfo).forEach(threadToInfoEntry -> {
Map<RecipientId, List<SyncMessageId>> 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<Long> 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) {

View File

@ -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;
}

View File

@ -47,6 +47,7 @@ public class EditProfileActivity extends BaseActionBarActivity implements EditPr
@Override
public void onProfileNameUploadCompleted() {
setResult(RESULT_OK);
finish();
}
}

View File

@ -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 {
}

View File

@ -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);
}
}
}

View File

@ -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;
}

View File

@ -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 "<b>" + target + "</b>";
}
}

View File

@ -0,0 +1,43 @@
package org.thoughtcrime.securesms.util;
import androidx.annotation.Nullable;
import androidx.core.util.ObjectsCompat;
public class Triple<A, B, C> {
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());
}
}

View File

@ -320,6 +320,17 @@ public class Util {
return Optional.fromNullable(simCountryIso != null ? simCountryIso.toUpperCase() : null);
}
@SafeVarargs
public static @NonNull <T> T firstNonNull(T ... ts) {
for (T t : ts) {
if (t != null) {
return t;
}
}
throw new IllegalStateException("All choices were null.");
}
public static <T> List<List<T>> partition(List<T> list, int partitionSize) {
List<List<T>> results = new LinkedList<>();

View File

@ -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<A, B, C> extends MediatorLiveData<Triple<A, B, C>> {
private A a;
private B b;
private C c;
public LiveDataTriple(@NonNull LiveData<A> liveDataA,
@NonNull LiveData<B> liveDataB,
@NonNull LiveData<C> liveDataC)
{
this(liveDataA, liveDataB, liveDataC, null, null, null);
}
public LiveDataTriple(@NonNull LiveData<A> liveDataA,
@NonNull LiveData<B> liveDataB,
@NonNull LiveData<C> 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));
});
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,6 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="40dp"
android:height="40dp"
android:width="34dp"
android:height="34dp"
android:viewportWidth="40"
android:viewportHeight="40">
<path

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="40"
android:viewportHeight="40">
<path
android:fillColor="?conversation_subtitle_color"
android:pathData="M29,16.75a6.508,6.508 0,0 1,6.5 6.5L35.5,24L37,24v-0.75a8,8 0,0 0,-6.7 -7.885,6.5 6.5,0 1,0 -8.6,0 7.941,7.941 0,0 0,-2.711 0.971A6.5,6.5 0,1 0,9.7 25.365,8 8,0 0,0 3,33.25L3,34L4.5,34v-0.75a6.508,6.508 0,0 1,6.5 -6.5h6a6.508,6.508 0,0 1,6.5 6.5L23.5,34L25,34v-0.75a8,8 0,0 0,-6.7 -7.885,6.468 6.468,0 0,0 1.508,-7.771A6.453,6.453 0,0 1,23 16.75ZM14,25.5a5,5 0,1 1,5 -5A5,5 0,0 1,14 25.5ZM21,10.5a5,5 0,1 1,5 5A5,5 0,0 1,21 10.5Z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector android:autoMirrored="true" android:height="24dp"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@color/core_white" android:pathData="M18,3.5a0.55,0.55 0,0 1,0.55 0.55L18.55,20a0.55,0.55 0,0 1,-0.55 0.55L6.05,20.55A0.55,0.55 0,0 1,5.5 20L5.5,4.05a0.55,0.55 0,0 1,0.55 -0.55L18,3.5M18,2L6.05,2A2.05,2.05 0,0 0,4 4.05L4,20A2.05,2.05 0,0 0,6.05 22L18,22A2.05,2.05 0,0 0,20 20L20,4.05A2.05,2.05 0,0 0,18 2ZM17,6.5L7,6.5L7,8L17,8ZM17,9.5L7,9.5L7,11L17,11ZM17,12.5L7,12.5L7,14L17,14ZM15,15.5L7,15.5L7,17h8Z"/>
</vector>

View File

@ -0,0 +1,9 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="34dp"
android:height="34dp"
android:autoMirrored="true"
android:viewportWidth="40"
android:viewportHeight="40">
<path android:fillColor="@color/core_white" android:pathData="M29.25,4.5A2.25,2.25 0,0 1,31.5 6.75v26.5a2.25,2.25 0,0 1,-2.25 2.25L10.75,35.5A2.25,2.25 0,0 1,8.5 33.25L8.5,6.75A2.25,2.25 0,0 1,10.75 4.5h18.5m0,-1.5L10.75,3A3.76,3.76 0,0 0,7 6.75v26.5A3.76,3.76 0,0 0,10.75 37h18.5A3.76,3.76 0,0 0,33 33.25L33,6.75A3.76,3.76 0,0 0,29.25 3ZM29,11.5L11,11.5L11,13L29,13ZM29,16.5L11,16.5L11,18L29,18ZM29,21.5L11,21.5L11,23L29,23ZM25,26.5L11,26.5L11,28L25,28Z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector android:autoMirrored="true" android:height="80dp"
android:viewportHeight="80" android:viewportWidth="80"
android:width="80dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@color/core_white" android:pathData="M58,7.5A6.51,6.51 0,0 1,64.5 14L64.5,66A6.51,6.51 0,0 1,58 72.5L22,72.5A6.51,6.51 0,0 1,15.5 66L15.5,14A6.51,6.51 0,0 1,22 7.5L58,7.5M58,6L22,6a8,8 0,0 0,-8 8L14,66a8,8 0,0 0,8 8L58,74a8,8 0,0 0,8 -8L66,14a8,8 0,0 0,-8 -8ZM60,24L20,24v1.5L60,25.5ZM60,34L20,34v1.5L60,35.5ZM60,44L20,44v1.5L60,45.5ZM50,54L20,54v1.5L50,55.5Z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector android:autoMirrored="true" android:height="80dp"
android:viewportHeight="80" android:viewportWidth="80"
android:width="80dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@color/core_white" android:pathData="M50,41h-0.06a18,18 0,1 0,-19.88 0H30A23,23 0,0 0,7 64v1H8.5V64A21.52,21.52 0,0 1,30 42.5h2.82a17.93,17.93 0,0 0,14.36 0H50A21.52,21.52 0,0 1,71.5 64v1H73V64A23,23 0,0 0,50 41ZM40,42.5A16.5,16.5 0,1 1,56.5 26,16.5 16.5,0 0,1 40,42.5Z"/>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="40"
android:viewportHeight="40">
<path
android:fillColor="?conversation_subtitle_color"
android:pathData="M25.009,20.07a7.5,7.5 0,1 0,-10.018 0A8,8 0,0 0,8 28v1H9.5V28A6.508,6.508 0,0 1,16 21.5h8A6.508,6.508 0,0 1,30.5 28v1H32V28A8,8 0,0 0,25.009 20.07ZM20,20.5a6,6 0,1 1,6 -6A6,6 0,0 1,20 20.5Z"/>
</vector>

View File

@ -77,6 +77,7 @@
android:layout="@layout/conversation_activity_attachment_editor_stub" />
<FrameLayout
android:id="@+id/conversation_activity_panel_parent"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipChildren="false"
@ -86,6 +87,14 @@
<include layout="@layout/conversation_search_nav" />
<org.thoughtcrime.securesms.messagerequests.MessageRequestsBottomView
android:id="@+id/conversation_activity_message_request_bottom_bar"
android:background="?android:attr/windowBackground"
android:visibility="gone"
android:clickable="true"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</FrameLayout>
<Button
@ -140,14 +149,5 @@
</org.thoughtcrime.securesms.components.InputAwareLayout>
<FrameLayout
android:id="@+id/fragment_overlay_container"
android:visibility="gone"
android:focusableInTouchMode="true"
android:layout_marginTop="?attr/actionBarSize"
android:background="?conversation_background"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<include layout="@layout/conversation_reaction_scrubber" />
</FrameLayout>

View File

@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout">
<org.thoughtcrime.securesms.components.AvatarImageView
android:id="@+id/message_request_avatar"
android:layout_width="112dp"
android:layout_height="112dp"
android:layout_marginTop="32dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/message_request_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:gravity="center"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:textAppearance="@style/Signal.Text.MessageRequest.Title"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/message_request_avatar"
tools:text="Cayce Pollard" />
<TextView
android:id="@+id/message_request_subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:gravity="center"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:textAppearance="@style/Signal.Text.MessageRequest.Subtitle"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/message_request_title"
tools:text="\@caycepollard" />
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/message_request_description"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="15dp"
android:layout_marginBottom="15dp"
android:gravity="center"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:textAppearance="@style/Signal.Text.MessageRequest.Description"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/message_request_subtitle"
tools:text="Member of NYC Rock Climbers, Dinner Party and Friends" />
</merge>

View File

@ -5,6 +5,10 @@
android:layout_width="fill_parent"
android:layout_height="match_parent">
<include layout="@layout/conversation_item_banner"
android:id="@+id/empty_conversation_banner"
android:visibility="gone" />
<androidx.recyclerview.widget.RecyclerView
android:id="@android:id/list"
android:layout_width="match_parent"

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<org.thoughtcrime.securesms.conversation.ConversationBannerView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content" />

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content" />

View File

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout">
<TextView
android:id="@+id/message_request_question"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="11dp"
android:textAppearance="@style/Signal.Text.MessageRequest.Description"
android:paddingTop="16dp"
app:layout_constraintBottom_toTopOf="@id/message_request_block"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:text="Do you want to let Cayce Pollard message you? They won't know you've seen their message until you accept." />
<Button
android:id="@+id/message_request_block"
style="@style/Signal.MessageRequest.Button.Deny"
android:layout_marginStart="16dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="16dp"
android:text="@string/MessageRequestBottomView_block"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/message_request_delete"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent" />
<Button
android:id="@+id/message_request_delete"
style="@style/Signal.MessageRequest.Button.Deny"
android:layout_marginEnd="8dp"
android:text="@string/MessageRequestBottomView_delete"
app:layout_constraintBottom_toBottomOf="@id/message_request_block"
app:layout_constraintEnd_toStartOf="@+id/message_request_accept"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@+id/message_request_block"
app:layout_constraintTop_toTopOf="@id/message_request_block" />
<Button
android:id="@+id/message_request_accept"
style="@style/Signal.MessageRequest.Button.Accept"
android:layout_marginEnd="16dp"
android:text="@string/MessageRequestBottomView_accept"
app:layout_constraintBottom_toBottomOf="@id/message_request_block"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@+id/message_request_delete"
app:layout_constraintTop_toTopOf="@id/message_request_block" />
</merge>

View File

@ -1,119 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?conversation_background">
<org.thoughtcrime.securesms.components.AvatarImageView
android:id="@+id/message_request_avatar"
android:layout_width="112dp"
android:layout_height="112dp"
android:layout_marginTop="32dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/message_request_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="10dp"
android:layout_marginEnd="16dp"
android:gravity="center"
android:textAppearance="@style/Signal.Text.MessageRequest.Title"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/message_request_avatar"
tools:text="Cayce Pollard" />
<TextView
android:id="@+id/message_request_subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="2dp"
android:layout_marginEnd="16dp"
android:gravity="center"
android:textAppearance="@style/Signal.Text.MessageRequest.Subtitle"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/message_request_title"
tools:text="\@caycepollard" />
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/message_request_description"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="15dp"
android:layout_marginEnd="16dp"
android:gravity="center"
android:textAppearance="@style/Signal.Text.MessageRequest.Description"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/message_request_subtitle"
tools:text="Member of NYC Rock Climbers, Dinner Party and Friends" />
<FrameLayout
android:id="@+id/message_request_message"
android:layout_width="match_parent"
android:layout_height="0dip"
android:layout_marginTop="27dp"
android:layout_marginBottom="12dp"
app:layout_constrainedWidth="true"
app:layout_constraintBottom_toTopOf="@id/message_request_question"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/message_request_description" />
<TextView
android:id="@+id/message_request_question"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="11dp"
android:textAppearance="@style/Signal.Text.MessageRequest.Description"
app:layout_constraintBottom_toTopOf="@id/message_request_block"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:text="Do you want to let Cayce Pollard message you? They won't know you've seen their message until you accept." />
<Button
android:id="@+id/message_request_block"
style="@style/Signal.MessageRequest.Button.Deny"
android:layout_marginStart="16dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="16dp"
android:text="@string/MessageRequestBottomView_block"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/message_request_delete"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent" />
<Button
android:id="@+id/message_request_delete"
style="@style/Signal.MessageRequest.Button.Deny"
android:layout_marginEnd="8dp"
android:text="@string/MessageRequestBottomView_delete"
app:layout_constraintBottom_toBottomOf="@id/message_request_block"
app:layout_constraintEnd_toStartOf="@+id/message_request_accept"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@+id/message_request_block"
app:layout_constraintTop_toTopOf="@id/message_request_block" />
<Button
android:id="@+id/message_request_accept"
style="@style/Signal.MessageRequest.Button.Accept"
android:layout_marginEnd="16dp"
android:text="@string/MessageRequestBottomView_accept"
app:layout_constraintBottom_toBottomOf="@id/message_request_block"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@+id/message_request_delete"
app:layout_constraintTop_toTopOf="@id/message_request_block" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,59 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.airbnb.lottie.LottieAnimationView
android:id="@+id/message_requests_lottie"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/message_requests_title"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/message_requests_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="6dp"
android:layout_marginEnd="6dp"
android:layout_marginBottom="7dp"
android:gravity="center"
android:text="@string/MessageRequestsMegaphone__message_requests"
android:textAppearance="@style/TextAppearance.Signal.Title1"
app:layout_constraintBottom_toTopOf="@id/message_requests_description"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<TextView
android:id="@+id/message_requests_description"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginEnd="32dp"
android:layout_marginBottom="30dp"
android:gravity="center"
android:lineSpacingMultiplier="1.2"
android:text="@string/MessageRequestsMegaphone__users_can_now_choose_to_accept"
android:textSize="16sp"
app:layout_constraintBottom_toTopOf="@id/message_requests_confirm_profile_name"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<Button
android:id="@+id/message_requests_confirm_profile_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginEnd="24dp"
android:layout_marginBottom="33dp"
android:background="@drawable/cta_button_background"
android:text="@string/MessageRequestsMegaphone__add_profile_name"
android:textColor="@color/core_white"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?megaphone_background"
android:clickable="true"
android:paddingBottom="16dp">
<ImageView
android:id="@+id/popup_megaphone_image"
android:layout_width="64dp"
android:layout_height="64dp"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:scaleType="centerInside"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/profile_splash" />
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/popup_megaphone_title"
style="@style/Signal.Text.Body"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:fontFamily="sans-serif-medium"
app:layout_constraintEnd_toStartOf="@id/popup_x"
app:layout_constraintStart_toEndOf="@id/popup_megaphone_image"
app:layout_constraintTop_toTopOf="@id/popup_megaphone_image"
tools:text="Avengers HQ Destroyed!" />
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/popup_megaphone_body"
style="@style/Signal.Text.Preview"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:textColor="?megaphone_body_text_color"
app:layout_constraintEnd_toStartOf="@id/popup_x"
app:layout_constraintStart_toStartOf="@id/popup_megaphone_title"
app:layout_constraintTop_toBottomOf="@id/popup_megaphone_title"
tools:text="Where was the 'hero' Spider-Man during the battle?" />
<ImageView
android:id="@+id/popup_x"
android:layout_width="48dp"
android:layout_height="48dp"
android:paddingStart="12.5dp"
android:paddingTop="14dp"
android:paddingEnd="15.5dp"
android:paddingBottom="14dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_x_20" />
</androidx.constraintlayout.widget.ConstraintLayout>
</merge>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item android:title="@string/conversation__menu_view_all_media"
android:id="@+id/menu_view_media" />
</menu>

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item android:title="@string/convesation_group_options__recipients_list"
android:id="@+id/menu_group_recipients"
android:icon="@drawable/ic_group_solid_highlight_24"
app:showAsAction="ifRoom" />
<item android:id="@+id/menu_leave"
android:title="@string/conversation__menu_leave_group"
app:showAsAction="collapseActionView"/>
</menu>

File diff suppressed because one or more lines are too long

View File

@ -285,6 +285,7 @@
<string name="ConversationFragment_you_can_swipe_to_the_left_reply">You can swipe to the left on any message to quickly reply</string>
<string name="ConversationFragment_outgoing_view_once_media_files_are_automatically_removed">Outgoing view-once media files are automatically removed after they are sent</string>
<string name="ConversationFragment_you_already_viewed_this_message">You already viewed this message</string>
<string name="ConversationFragment__you_can_add_notes_for_yourself_in_this_conversation">You can add notes for yourself in this conversation. If your account has any linked devices, new notes will be synced.</string>
<!-- ConversationListActivity -->
<string name="ConversationListActivity_there_is_no_browser_installed_on_your_device">There is no browser installed on your device.</string>
@ -311,6 +312,8 @@
<item quantity="one">Moved conversation to inbox</item>
<item quantity="other">Moved %d conversations to inbox</item>
</plurals>
<string name="ConversationListFragment__your_profile_name_has_been_created">Your profile name has been created.</string>
<string name="ConversationListFragment__your_profile_name_has_been_saved">Your profile name has been saved.</string>
<!-- ConversationListItem -->
<string name="ConversationListItem_key_exchange_message">Key exchange message</string>
@ -847,6 +850,8 @@
<string name="ThreadRecord_you_marked_verified">You marked verified</string>
<string name="ThreadRecord_you_marked_unverified">You marked unverified</string>
<string name="ThreadRecord_message_could_not_be_processed">Message could not be processed</string>
<string name="ThreadRecord_message_request">Message Request</string>
<string name="ThreadRecord_s_added_you_to_the_group">%1$s added you to the group</string>
<!-- UpdateApkReadyListener -->
<string name="UpdateApkReadyListener_Signal_update">Signal update</string>
@ -1365,6 +1370,14 @@
<string name="AndroidManifest_archived_conversations">Archived conversations</string>
<string name="AndroidManifest_remove_photo">Remove photo</string>
<!-- Message Requests Megaphone -->
<string name="MessageRequestsMegaphone__message_requests">Message requests</string>
<string name="MessageRequestsMegaphone__users_can_now_choose_to_accept">Users can now choose to accept a new conversation. Profile names let people know who\'s messaging them.</string>
<string name="MessageRequestsMegaphone__add_profile_name">Add profile name</string>
<string name="MessageRequestsMegaphone__new_message_requests">New: Message requests</string>
<string name="MessageRequestsMegaphone__add_name">Add name</string>
<string name="MessageRequestsMegaphone__you_can_now_choose_whether_to_accept">You can now choose whether to accept a new conversation. Youll see options to \"Accept,\" \"Delete,\" or \"Block.\"</string>
<!-- arrays.xml -->
<string name="arrays__import_export">Import</string>
<string name="arrays__use_default">Use default</string>
@ -1923,7 +1936,10 @@
<string name="MessageRequestProfileView_member_of_one_group">Member of %1$s</string>
<string name="MessageRequestProfileView_member_of_two_groups">Member of %1$s and %2$s</string>
<string name="MessageRequestProfileView_member_of_many_groups">Member of %1$s, %2$s, and %3$s</string>
<string name="MessageRequestProfileView_members">%1$d members</string>
<plurals name="MessageRequestProfileView_members">
<item quantity="one">%1$d member</item>
<item quantity="other">%1$d members</item>
</plurals>
<plurals name="MessageRequestProfileView_member_of_others">
<item quantity="one">%d other</item>
<item quantity="other">%d others</item>

View File

@ -0,0 +1,102 @@
package org.thoughtcrime.securesms.jobmanager.migrations;
import org.junit.Test;
import org.thoughtcrime.securesms.database.MmsSmsDatabase;
import org.thoughtcrime.securesms.jobmanager.Data;
import org.thoughtcrime.securesms.jobmanager.JobMigration;
import org.thoughtcrime.securesms.jobs.SendReadReceiptJob;
import org.thoughtcrime.securesms.recipients.RecipientId;
import java.util.ArrayList;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.Matchers.anyLong;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class SendReadReceiptsJobMigrationTest {
private final MmsSmsDatabase mockDatabase = mock(MmsSmsDatabase.class);
private final SendReadReceiptsJobMigration testSubject = new SendReadReceiptsJobMigration(mockDatabase);
@Test
public void givenSendReadReceiptJobDataWithoutThreadIdAndThreadIdFound_whenIMigrate_thenIInsertThreadId() {
// GIVEN
SendReadReceiptJob job = new SendReadReceiptJob(1, RecipientId.from(2), new ArrayList<>());
JobMigration.JobData jobData = new JobMigration.JobData(job.getFactoryKey(),
"asdf",
new Data.Builder()
.putString("recipient", RecipientId.from(2).serialize())
.putLongArray("message_ids", new long[]{1, 2, 3, 4, 5})
.putLong("timestamp", 292837649).build());
when(mockDatabase.getThreadForMessageId(anyLong())).thenReturn(1234L);
// WHEN
JobMigration.JobData result = testSubject.migrate(jobData);
// THEN
assertEquals(1234L, result.getData().getLong("thread"));
assertEquals(RecipientId.from(2).serialize(), result.getData().getString("recipient"));
assertTrue(result.getData().hasLongArray("message_ids"));
assertTrue(result.getData().hasLong("timestamp"));
}
@Test
public void givenSendReadReceiptJobDataWithoutThreadIdAndThreadIdNotFound_whenIMigrate_thenIGetAFailingJob() {
// GIVEN
SendReadReceiptJob job = new SendReadReceiptJob(1, RecipientId.from(2), new ArrayList<>());
JobMigration.JobData jobData = new JobMigration.JobData(job.getFactoryKey(),
"asdf",
new Data.Builder()
.putString("recipient", RecipientId.from(2).serialize())
.putLongArray("message_ids", new long[]{})
.putLong("timestamp", 292837649).build());
when(mockDatabase.getThreadForMessageId(anyLong())).thenReturn(-1L);
// WHEN
JobMigration.JobData result = testSubject.migrate(jobData);
// THEN
assertEquals("FailingJob", result.getFactoryKey());
}
@Test
public void givenSendReadReceiptJobDataWithThreadId_whenIMigrate_thenIDoNotReplace() {
// GIVEN
SendReadReceiptJob job = new SendReadReceiptJob(1, RecipientId.from(2), new ArrayList<>());
JobMigration.JobData jobData = new JobMigration.JobData(job.getFactoryKey(), "asdf", job.serialize());
// WHEN
JobMigration.JobData result = testSubject.migrate(jobData);
// THEN
assertEquals(jobData, result);
}
@Test
public void givenSomeOtherJobDataWithThreadId_whenIMigrate_thenIDoNotReplace() {
// GIVEN
JobMigration.JobData jobData = new JobMigration.JobData("SomeOtherJob", "asdf", new Data.Builder().putLong("thread", 1).build());
// WHEN
JobMigration.JobData result = testSubject.migrate(jobData);
// THEN
assertEquals(jobData, result);
}
@Test
public void givenSomeOtherJobDataWithoutThreadId_whenIMigrate_thenIDoNotReplace() {
// GIVEN
JobMigration.JobData jobData = new JobMigration.JobData("SomeOtherJob", "asdf", new Data.Builder().build());
// WHEN
JobMigration.JobData result = testSubject.migrate(jobData);
// THEN
assertEquals(jobData, result);
}
}

View File

@ -0,0 +1,101 @@
package org.thoughtcrime.securesms.notifications;
import android.content.Context;
import androidx.annotation.NonNull;
import com.annimon.stream.Stream;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.invocation.InvocationOnMock;
import org.mockito.stubbing.Answer;
import org.powermock.core.classloader.annotations.PrepareForTest;
import org.powermock.modules.junit4.PowerMockRunner;
import org.thoughtcrime.securesms.database.MessagingDatabase;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobmanager.Data;
import org.thoughtcrime.securesms.jobmanager.Job;
import org.thoughtcrime.securesms.jobmanager.JobManager;
import org.thoughtcrime.securesms.jobs.MultiDeviceReadUpdateJob;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.libsignal.util.Pair;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.Matchers.any;
import static org.powermock.api.mockito.PowerMockito.doAnswer;
import static org.powermock.api.mockito.PowerMockito.mock;
import static org.powermock.api.mockito.PowerMockito.mockStatic;
import static org.powermock.api.mockito.PowerMockito.when;
@RunWith(PowerMockRunner.class)
@PrepareForTest(ApplicationDependencies.class)
public class MarkReadReceiverTest {
private final Context mockContext = mock(Context.class);
private final JobManager mockJobManager = mock(JobManager.class);
private final List<Job> jobs = new LinkedList<>();
@Before
public void setUp() {
mockStatic(ApplicationDependencies.class);
when(ApplicationDependencies.getJobManager()).thenReturn(mockJobManager);
doAnswer((Answer<Void>) invocation -> {
jobs.add((Job) invocation.getArguments()[0]);
return null;
}).when(mockJobManager).add(any());
}
@Test
public void givenMultipleThreadsWithMultipleMessagesEach_whenIProcess_thenIProperlyGroupByThreadAndRecipient() {
// GIVEN
List<RecipientId> recipients = Stream.range(1L, 4L).map(RecipientId::from).toList();
List<Long> threads = Stream.range(4L, 7L).toList();
int expected = recipients.size() * threads.size() + 1;
List<MessagingDatabase.MarkedMessageInfo> infoList = Stream.of(threads)
.flatMap(threadId -> Stream.of(recipients)
.map(recipientId -> createMarkedMessageInfo(threadId, recipientId)))
.toList();
List<MessagingDatabase.MarkedMessageInfo> duplicatedList = Util.concatenatedList(infoList, infoList);
// WHEN
MarkReadReceiver.process(mockContext, duplicatedList);
// THEN
assertEquals("Should have 10 total jobs, including MultiDeviceReadUpdateJob", expected, jobs.size());
Set<Pair<Long, String>> threadRecipientPairs = new HashSet<>();
Stream.of(jobs).forEach(job -> {
if (job instanceof MultiDeviceReadUpdateJob) {
return;
}
Data data = job.serialize();
long threadId = data.getLong("thread");
String recipientId = data.getString("recipient");
long[] messageIds = data.getLongArray("message_ids");
assertEquals("Each job should contain two messages.", 2, messageIds.length);
assertTrue("Each thread recipient pair should only exist once.", threadRecipientPairs.add(new Pair<>(threadId, recipientId)));
});
assertEquals("Should have 9 total combinations.", 9, threadRecipientPairs.size());
}
private MessagingDatabase.MarkedMessageInfo createMarkedMessageInfo(long threadId, @NonNull RecipientId recipientId) {
return new MessagingDatabase.MarkedMessageInfo(threadId,
new MessagingDatabase.SyncMessageId(recipientId, 0),
new MessagingDatabase.ExpirationInfo(0, 0, 0, false));
}
}

View File

@ -0,0 +1,253 @@
package org.thoughtcrime.securesms.recipients;
import android.content.Context;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.powermock.core.classloader.annotations.PrepareForTest;
import org.powermock.modules.junit4.PowerMockRunner;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MmsSmsDatabase;
import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.util.FeatureFlags;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.Matchers.any;
import static org.mockito.Matchers.anyLong;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.powermock.api.mockito.PowerMockito.mockStatic;
import static org.powermock.api.mockito.PowerMockito.when;
@RunWith(PowerMockRunner.class)
@PrepareForTest({DatabaseFactory.class, FeatureFlags.class})
public class RecipientUtilTest {
private Context context = mock(Context.class);
private Recipient recipient = mock(Recipient.class);
private ThreadDatabase mockThreadDatabase = mock(ThreadDatabase.class);
private MmsSmsDatabase mockMmsSmsDatabase = mock(MmsSmsDatabase.class);
private RecipientDatabase mockRecipientDatabase = mock(RecipientDatabase.class);
@Before
public void setUp() {
mockStatic(DatabaseFactory.class);
when(DatabaseFactory.getThreadDatabase(any())).thenReturn(mockThreadDatabase);
when(DatabaseFactory.getMmsSmsDatabase(any())).thenReturn(mockMmsSmsDatabase);
when(DatabaseFactory.getRecipientDatabase(any())).thenReturn(mockRecipientDatabase);
mockStatic(FeatureFlags.class);
when(FeatureFlags.messageRequests()).thenReturn(true);
when(recipient.getId()).thenReturn(RecipientId.from(5));
when(recipient.resolve()).thenReturn(recipient);
}
@Test
public void givenMessageRequestsFlagDisabled_whenIsThreadMessageRequestAccepted_thenIExpectTrue() {
// GIVEN
when(FeatureFlags.messageRequests()).thenReturn(false);
// WHEN
boolean result = RecipientUtil.isThreadMessageRequestAccepted(context, 1);
// THEN
assertTrue(result);
}
@Test
public void givenThreadIsNegativeOne_whenIsThreadMessageRequestAccepted_thenIExpectTrue() {
// WHEN
boolean result = RecipientUtil.isThreadMessageRequestAccepted(context, -1L);
// THEN
assertTrue(result);
}
@Test
public void givenRecipientIsNullForThreadId_whenIsThreadMessageRequestAccepted_thenIExpectTrue() {
// WHEN
boolean result = RecipientUtil.isThreadMessageRequestAccepted(context, 1L);
// THEN
assertTrue(result);
}
@Test
public void givenIHaveSentASecureMessageInThisThread_whenIsThreadMessageRequestAccepted_thenIExpectTrue() {
// GIVEN
when(mockThreadDatabase.getRecipientForThreadId(anyLong())).thenReturn(recipient);
when(mockMmsSmsDatabase.getOutgoingSecureConversationCount(1L)).thenReturn(5);
// WHEN
boolean result = RecipientUtil.isThreadMessageRequestAccepted(context, 1L);
// THEN
assertTrue(result);
}
@Test
public void givenIHaveNotSentASecureMessageInThisThreadAndIAmProfileSharing_whenIsThreadMessageRequestAccepted_thenIExpectTrue() {
// GIVEN
when(recipient.isProfileSharing()).thenReturn(true);
when(mockThreadDatabase.getRecipientForThreadId(anyLong())).thenReturn(recipient);
when(mockMmsSmsDatabase.getOutgoingSecureConversationCount(1L)).thenReturn(0);
// WHEN
boolean result = RecipientUtil.isThreadMessageRequestAccepted(context, 1L);
// THEN
assertTrue(result);
}
@Test
public void givenIHaveNotSentASecureMessageInThisThreadAndRecipientIsSystemContact_whenIsThreadMessageRequestAccepted_thenIExpectTrue() {
// GIVEN
when(recipient.isSystemContact()).thenReturn(true);
when(mockThreadDatabase.getRecipientForThreadId(anyLong())).thenReturn(recipient);
when(mockMmsSmsDatabase.getOutgoingSecureConversationCount(1L)).thenReturn(0);
// WHEN
boolean result = RecipientUtil.isThreadMessageRequestAccepted(context, 1L);
// THEN
assertTrue(result);
}
@Test
public void givenIHaveReceivedASecureMessageIHaveNotSentASecureMessageAndRecipientIsNotSystemContactAndNotProfileSharing_whenIsThreadMessageRequestAccepted_thenIExpectFalse() {
// GIVEN
when(mockThreadDatabase.getRecipientForThreadId(anyLong())).thenReturn(recipient);
when(mockMmsSmsDatabase.getOutgoingSecureConversationCount(1L)).thenReturn(0);
when(mockMmsSmsDatabase.getSecureConversationCount(1L)).thenReturn(5);
// WHEN
boolean result = RecipientUtil.isThreadMessageRequestAccepted(context, 1L);
// THEN
assertFalse(result);
}
@Test
public void givenIHaveNotReceivedASecureMessageIHaveNotSentASecureMessageAndRecipientIsNotSystemContactAndNotProfileSharing_whenIsThreadMessageRequestAccepted_thenIExpectTrue() {
// GIVEN
when(mockThreadDatabase.getRecipientForThreadId(anyLong())).thenReturn(recipient);
when(mockMmsSmsDatabase.getOutgoingSecureConversationCount(1L)).thenReturn(0);
when(mockMmsSmsDatabase.getSecureConversationCount(1L)).thenReturn(0);
// WHEN
boolean result = RecipientUtil.isThreadMessageRequestAccepted(context, 1L);
// THEN
assertTrue(result);
}
@Test
public void givenRecipientIsNull_whenIsRecipientMessageRequestAccepted_thenIExpectTrue() {
// WHEN
boolean result = RecipientUtil.isRecipientMessageRequestAccepted(context, null);
// THEN
assertTrue(result);
}
@Test
public void givenMessageRequestsFlagIsOff_whenIsRecipientMessageRequestAccepted_thenIExpectTrue() {
// GIVEN
when(FeatureFlags.messageRequests()).thenReturn(false);
// WHEN
boolean result = RecipientUtil.isRecipientMessageRequestAccepted(context, recipient);
// THEN
assertTrue(result);
}
@Test
public void givenNonZeroOutgoingSecureMessageCount_whenIsRecipientMessageRequestAccepted_thenIExpectTrue() {
// GIVEN
when(mockMmsSmsDatabase.getOutgoingSecureConversationCount(anyLong())).thenReturn(1);
// WHEN
boolean result = RecipientUtil.isRecipientMessageRequestAccepted(context, recipient);
// THEN
assertTrue(result);
}
@Test
public void givenIAmProfileSharing_whenIsRecipientMessageRequestAccepted_thenIExpectTrue() {
// GIVEN
when(recipient.isProfileSharing()).thenReturn(true);
// WHEN
boolean result = RecipientUtil.isRecipientMessageRequestAccepted(context, recipient);
// THEN
assertTrue(result);
}
@Test
public void givenRecipientIsASystemContact_whenIsRecipientMessageRequestAccepted_thenIExpectTrue() {
// GIVEN
when(recipient.isSystemContact()).thenReturn(true);
// WHEN
boolean result = RecipientUtil.isRecipientMessageRequestAccepted(context, recipient);
// THEN
assertTrue(result);
}
@Test
public void givenNoSecureMessagesSentSomeSecureMessagesReceivedNotSharingAndNotSystemContact_whenIsRecipientMessageRequestAccepted_thenIExpectFalse() {
// GIVEN
when(mockMmsSmsDatabase.getSecureConversationCount(anyLong())).thenReturn(5);
// WHEN
boolean result = RecipientUtil.isRecipientMessageRequestAccepted(context, recipient);
// THEN
assertFalse(result);
}
@Test
public void givenNoSecureMessagesSentNoSecureMessagesReceivedNotSharingAndNotSystemContact_whenIsRecipientMessageRequestAccepted_thenIExpectTrue() {
// GIVEN
when(mockMmsSmsDatabase.getSecureConversationCount(anyLong())).thenReturn(0);
// WHEN
boolean result = RecipientUtil.isRecipientMessageRequestAccepted(context, recipient);
// THEN
assertTrue(result);
}
@Test
public void givenNoSecureMessagesSent_whenIShareProfileIfFirstSecureMessage_thenIShareProfile() {
// GIVEN
when(mockMmsSmsDatabase.getOutgoingSecureConversationCount(anyLong())).thenReturn(0);
// WHEN
RecipientUtil.shareProfileIfFirstSecureMessage(context, recipient);
// THEN
verify(mockRecipientDatabase).setProfileSharing(recipient.getId(), true);
}
@Test
public void givenSecureMessagesSent_whenIShareProfileIfFirstSecureMessage_thenIShareProfile() {
// GIVEN
when(mockMmsSmsDatabase.getOutgoingSecureConversationCount(anyLong())).thenReturn(5);
// WHEN
RecipientUtil.shareProfileIfFirstSecureMessage(context, recipient);
// THEN
verify(mockRecipientDatabase, never()).setProfileSharing(recipient.getId(), true);
}
}